Refactor auth token generation and handling

Separate responsibilities:
* introduce LfsCipher that is responsible only for encryption/decryption
of provided string
* introduce LfsAuthToken that contains by default expiresAt field and
defines two helpers:
- Processor (responsible for serialization
and de-serialization of token to/from string using LfsCipher)
- Verifier (responsible for token validation against time and
implementation specific fields)

Dedicated implementation of LfsAuthToken and its helpers was introduced
for LfsFsRequestAuthorizer so that it might be used in Git LFS API
response (see [1]).

[1]
https://github.com/git-lfs/git-lfs/blob/master/docs/api/authentication.md
Change-Id: Id38576572537e733a584146effb9a98f3199ee8c
Signed-off-by: Jacek Centkowski <jcentkowski@collab.net>
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsAuthToken.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsAuthToken.java
new file mode 100644
index 0000000..1ab35c4
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsAuthToken.java
@@ -0,0 +1,93 @@
+//Copyright (C) 2017 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.lfs;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Optional;
+import com.google.common.base.Splitter;
+
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.joda.time.format.DateTimeFormatter;
+import org.joda.time.format.ISODateTimeFormat;
+
+import java.util.List;
+
+public abstract class LfsAuthToken {
+  public static abstract class Processor<T extends LfsAuthToken> {
+    private static final char DELIMETER = '~';
+
+    protected final LfsCipher cipher;
+
+    protected Processor(LfsCipher cipher) {
+      this.cipher = cipher;
+    }
+
+    public String serialize(T token) {
+      return cipher.encrypt(Joiner.on(DELIMETER).join(getValues(token)));
+    }
+
+    public Optional<T> deserialize(String input) {
+      Optional<String> decrypted = cipher.decrypt(input);
+      if (!decrypted.isPresent()) {
+        return Optional.absent();
+      }
+
+      return createToken(Splitter.on(DELIMETER).splitToList(decrypted.get()));
+    }
+
+    protected abstract List<String> getValues(T token);
+    protected abstract Optional<T> createToken(List<String> values);
+  }
+
+  public static abstract class Verifier<T extends LfsAuthToken> {
+    protected final T token;
+
+    protected Verifier(T token) {
+      this.token = token;
+    }
+
+    public boolean verify() {
+      return onTime(token.expiresAt)
+          && verifyTokenValues();
+    }
+
+    protected abstract boolean verifyTokenValues();
+
+    static boolean onTime(String dateTime) {
+      String now = LfsAuthToken.ISO.print(now());
+      return now.compareTo(dateTime) <= 0;
+    }
+  }
+
+  static final DateTimeFormatter ISO = ISODateTimeFormat.dateTime();
+  public final String expiresAt;
+
+  protected LfsAuthToken(int expirationSeconds) {
+    this(timeout(expirationSeconds));
+  }
+
+  protected LfsAuthToken(String expiresAt) {
+    this.expiresAt = expiresAt;
+  }
+
+  static String timeout(int expirationSeconds) {
+    return LfsAuthToken.ISO.print(now().plusSeconds(expirationSeconds));
+  }
+
+  static DateTime now() {
+    return DateTime.now().toDateTime(DateTimeZone.UTC);
+  }
+}
\ No newline at end of file
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsCipher.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsCipher.java
new file mode 100644
index 0000000..25238da
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/LfsCipher.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2017 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.lfs;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.base.Optional;
+import com.google.common.base.Strings;
+import com.google.common.primitives.Bytes;
+import com.google.inject.Singleton;
+
+import org.eclipse.jgit.util.Base64;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.security.AlgorithmParameters;
+import java.security.GeneralSecurityException;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Arrays;
+
+import javax.crypto.Cipher;
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.IvParameterSpec;
+
+@Singleton
+public class LfsCipher {
+  private static final Logger log =
+      LoggerFactory.getLogger(LfsCipher.class);
+  private static final int IV_LENGTH = 16;
+  private static final String ALGORITHM = "AES";
+  private static final String CIPHER_TYPE = ALGORITHM + "/CBC/PKCS5PADDING";
+  private static final int KEY_SIZE = 128;
+
+  private final SecureRandom random;
+  private final SecretKey key;
+
+  public LfsCipher() {
+    this.random = new SecureRandom();
+    this.key = generateKey();
+  }
+
+  public String encrypt(String input) {
+    try {
+      byte[] initVector = new byte[IV_LENGTH];
+      random.nextBytes(initVector);
+      Cipher cipher = cipher(initVector, Cipher.ENCRYPT_MODE);
+      return Base64.encodeBytes(
+          Bytes.concat(initVector, cipher.doFinal(input.getBytes(UTF_8))));
+    } catch (GeneralSecurityException e) {
+      log.error("Token generation failed with error", e);
+      throw new RuntimeException(e);
+    }
+  }
+
+  public Optional<String> decrypt(String input) {
+    if (Strings.isNullOrEmpty(input)) {
+      return Optional.absent();
+    }
+
+    byte[] bytes = Base64.decode(input);
+    byte[] initVector = Arrays.copyOf(bytes, IV_LENGTH);
+    try {
+      Cipher cipher = cipher(initVector, Cipher.DECRYPT_MODE);
+      return Optional.of(new String(
+          cipher.doFinal(Arrays.copyOfRange(bytes, IV_LENGTH, bytes.length)),
+          UTF_8));
+    } catch (GeneralSecurityException e) {
+      log.error("Exception was thrown during token verification", e);
+    }
+
+    return Optional.absent();
+  }
+
+  private Cipher cipher(byte[] initVector, int mode)
+      throws GeneralSecurityException {
+    IvParameterSpec spec = new IvParameterSpec(initVector);
+    AlgorithmParameters params = AlgorithmParameters.getInstance(ALGORITHM);
+    params.init(spec);
+    Cipher cipher = Cipher.getInstance(CIPHER_TYPE);
+    cipher.init(mode, key, params);
+    return cipher;
+  }
+
+  private SecretKey generateKey() {
+    try {
+      KeyGenerator generator = KeyGenerator.getInstance(ALGORITHM);
+      generator.init(KEY_SIZE, random);
+      return generator.generateKey();
+    } catch (NoSuchAlgorithmException e) {
+      log.error("Generating key failed with error", e);
+      throw new RuntimeException(e);
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizer.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizer.java
index b3df46d..4163f2f 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizer.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizer.java
@@ -14,130 +14,113 @@
 
 package com.googlesource.gerrit.plugins.lfs.fs;
 
-import com.google.common.base.Strings;
-import com.google.common.primitives.Bytes;
+import com.google.common.base.Optional;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import com.googlesource.gerrit.plugins.lfs.LfsAuthToken;
+import com.googlesource.gerrit.plugins.lfs.LfsCipher;
+
 import org.eclipse.jgit.lfs.lib.AnyLongObjectId;
-import org.eclipse.jgit.util.Base64;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
-import org.joda.time.format.DateTimeFormat;
-import org.joda.time.format.DateTimeFormatter;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import org.eclipse.jgit.lfs.lib.LongObjectId;
 
-import java.nio.charset.StandardCharsets;
-import java.security.AlgorithmParameters;
-import java.security.GeneralSecurityException;
-import java.security.InvalidAlgorithmParameterException;
-import java.security.InvalidKeyException;
-import java.security.NoSuchAlgorithmException;
-import java.security.SecureRandom;
-import java.security.spec.InvalidParameterSpecException;
-import java.util.Arrays;
-
-import javax.crypto.Cipher;
-import javax.crypto.KeyGenerator;
-import javax.crypto.NoSuchPaddingException;
-import javax.crypto.SecretKey;
-import javax.crypto.spec.IvParameterSpec;
+import java.util.ArrayList;
+import java.util.List;
 
 @Singleton
 public class LfsFsRequestAuthorizer {
-  private static final Logger log = LoggerFactory.getLogger(LfsFsRequestAuthorizer.class);
-  private static final int IV_LENGTH = 16;
-  private static final String ALGORITHM = "AES";
-  static final DateTimeFormatter DATE_TIME =
-      DateTimeFormat.forPattern("YYYYMMDDHHmmss");
+  class AuthInfo {
+    public final String authToken;
+    public final String expiresAt;
 
-  private final SecureRandom rndm;
-  private final SecretKey key;
+    AuthInfo(String authToken, String expiresAt) {
+      this.authToken = authToken;
+      this.expiresAt = expiresAt;
+    }
+  }
+
+  private final Processor processor;
 
   @Inject
-  LfsFsRequestAuthorizer() {
-    this.rndm = new SecureRandom();
-    this.key = generateKey();
+  LfsFsRequestAuthorizer(Processor processor) {
+    this.processor = processor;
   }
 
-  public String generateToken(String operation, AnyLongObjectId id,
+  public AuthInfo generateAuthInfo(String operation, AnyLongObjectId id,
       int expirationSeconds) {
-    try {
-      byte[] initVector = new byte[IV_LENGTH];
-      rndm.nextBytes(initVector);
-      Cipher cipher = cipher(initVector, Cipher.ENCRYPT_MODE);
-      return Base64.encodeBytes(Bytes.concat(initVector,
-          cipher.doFinal(String.format("%s~%s~%s", operation,
-              id.name(), timeout(expirationSeconds))
-              .getBytes(StandardCharsets.UTF_8))));
-    } catch (GeneralSecurityException e) {
-      log.error("Token generation failed with error", e);
-      throw new RuntimeException(e);
-    }
+    LfsFsAuthToken token = new LfsFsAuthToken(operation, id, expirationSeconds);
+    return new AuthInfo(processor.serialize(token), token.expiresAt);
   }
 
-  public boolean verifyAgainstToken(String token, String operation,
+  public boolean verifyAuthInfo(String authToken, String operation,
       AnyLongObjectId id) {
-    if (Strings.isNullOrEmpty(token)) {
+    Optional<LfsFsAuthToken> token = processor.deserialize(authToken);
+    if (!token.isPresent()) {
       return false;
     }
 
-    byte[] bytes = Base64.decode(token);
-    byte[] initVector = Arrays.copyOf(bytes, IV_LENGTH);
-    try {
-      Cipher cipher = cipher(initVector, Cipher.DECRYPT_MODE);
-      String data = new String(
-          cipher.doFinal(Arrays.copyOfRange(bytes, IV_LENGTH, bytes.length)),
-          StandardCharsets.UTF_8);
-      String oid = id.name();
-      String prefix = String.format("%s~%s~", operation, oid);
-      return data.startsWith(prefix)
-          && onTime(data.substring(prefix.length()), operation, oid);
-    } catch (GeneralSecurityException e) {
-      log.error("Exception was thrown during token verification", e);
+    return new Verifier(token.get(), operation, id).verify();
+  }
+
+  static class Processor extends LfsAuthToken.Processor<LfsFsAuthToken> {
+    @Inject
+    protected Processor(LfsCipher cipher) {
+      super(cipher);
     }
 
-    return false;
-  }
-
-  boolean onTime(String dateTime, String operation, String id) {
-    String now = DATE_TIME.print(now());
-    if (now.compareTo(dateTime) > 0) {
-      log.info("Operation {} on id {} timed out", operation, id);
-      return false;
+    @Override
+    protected List<String> getValues(LfsFsAuthToken token) {
+      List<String> values = new ArrayList<>(3);
+      values.add(token.operation);
+      values.add(token.id.getName());
+      values.add(token.expiresAt);
+      return values;
     }
 
-    return true;
+    @Override
+    protected Optional<LfsFsAuthToken> createToken(List<String> values) {
+      if (values.size() != 3) {
+        return Optional.absent();
+      }
+
+      return Optional.of(new LfsFsAuthToken(values.get(0),
+          LongObjectId.fromString(values.get(1)), values.get(2)));
+    }
   }
 
-  private String timeout(int expirationSeconds) {
-    return DATE_TIME.print(now().plusSeconds(expirationSeconds));
+  private static class Verifier extends LfsAuthToken.Verifier<LfsFsAuthToken> {
+    private final String operation;
+    private final AnyLongObjectId id;
+
+    protected Verifier(LfsFsAuthToken token,
+        String operation, AnyLongObjectId id) {
+      super(token);
+      this.operation = operation;
+      this.id = id;
+    }
+
+    @Override
+    protected boolean verifyTokenValues() {
+      return operation.equals(token.operation)
+          && id.getName().equals(token.id.getName());
+    }
   }
 
-  private DateTime now() {
-    return DateTime.now().toDateTime(DateTimeZone.UTC);
-  }
+  private static class LfsFsAuthToken extends LfsAuthToken {
+    private final String operation;
+    private final AnyLongObjectId id;
 
-  private Cipher cipher(byte[] initVector, int mode) throws NoSuchAlgorithmException,
-      NoSuchPaddingException, InvalidParameterSpecException,
-      InvalidKeyException, InvalidAlgorithmParameterException {
-    IvParameterSpec spec = new IvParameterSpec(initVector);
-    Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5PADDING");
-    AlgorithmParameters params = AlgorithmParameters.getInstance(ALGORITHM);
-    params.init(spec);
-    cipher.init(mode, key, params);
-    return cipher;
-  }
+    LfsFsAuthToken(String operation, AnyLongObjectId id,
+        int expirationSeconds) {
+      super(expirationSeconds);
+      this.operation = operation;
+      this.id = id;
+    }
 
-  private SecretKey generateKey() {
-    try {
-      KeyGenerator generator = KeyGenerator.getInstance(ALGORITHM);
-      generator.init(128, rndm);
-      return generator.generateKey();
-    } catch (NoSuchAlgorithmException e) {
-      log.error("Generating key failed with error", e);
-      throw new RuntimeException(e);
+    LfsFsAuthToken(String operation, AnyLongObjectId id, String expiresAt) {
+      super(expiresAt);
+      this.operation = operation;
+      this.id = id;
     }
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LocalLargeFileRepository.java b/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LocalLargeFileRepository.java
index a810943..2607329 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LocalLargeFileRepository.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/lfs/fs/LocalLargeFileRepository.java
@@ -74,17 +74,17 @@
   @Override
   public Response.Action getDownloadAction(AnyLongObjectId id) {
     Response.Action action = super.getDownloadAction(id);
-    action.header = Collections.singletonMap(HDR_AUTHORIZATION,
-        authorizer.generateToken(DOWNLOAD, id, expirationSeconds));
-    return action;
+    LfsFsRequestAuthorizer.AuthInfo authInfo =
+        authorizer.generateAuthInfo(DOWNLOAD, id, expirationSeconds);
+    return new ExpiringAction(action.href, authInfo);
   }
 
   @Override
   public Response.Action getUploadAction(AnyLongObjectId id, long size) {
     Response.Action action = super.getUploadAction(id, size);
-    action.header = Collections.singletonMap(HDR_AUTHORIZATION,
-        authorizer.generateToken(UPLOAD, id, expirationSeconds));
-    return action;
+    LfsFsRequestAuthorizer.AuthInfo authInfo =
+        authorizer.generateAuthInfo(UPLOAD, id, expirationSeconds);
+    return new ExpiringAction(action.href, authInfo);
   }
 
   private static String getContentUrl(String url, LfsBackend backend) {
@@ -120,4 +120,14 @@
 
     return ensured;
   }
+
+  class ExpiringAction extends Response.Action {
+    public final String expiresAt;
+
+    ExpiringAction(String href, LfsFsRequestAuthorizer.AuthInfo info) {
+      this.href = href;
+      this.header = Collections.singletonMap(HDR_AUTHORIZATION, info.authToken);
+      this.expiresAt = info.expiresAt;
+    }
+  }
 }
diff --git a/src/main/java/org/eclipse/jgit/lfs/server/fs/LfsFsContentServlet.java b/src/main/java/org/eclipse/jgit/lfs/server/fs/LfsFsContentServlet.java
index b523795..90f2778 100644
--- a/src/main/java/org/eclipse/jgit/lfs/server/fs/LfsFsContentServlet.java
+++ b/src/main/java/org/eclipse/jgit/lfs/server/fs/LfsFsContentServlet.java
@@ -75,7 +75,7 @@
       return;
     }
 
-    if (!authorizer.verifyAgainstToken(req.getHeader(HDR_AUTHORIZATION),
+    if (!authorizer.verifyAuthInfo(req.getHeader(HDR_AUTHORIZATION),
         DOWNLOAD, obj)) {
       sendError(rsp, HttpStatus.SC_UNAUTHORIZED, MessageFormat.format(
           LfsServerText.get().failedToCalcSignature, "Invalid authorization token"));
@@ -96,7 +96,7 @@
       return;
     }
 
-    if (!authorizer.verifyAgainstToken(
+    if (!authorizer.verifyAuthInfo(
         req.getHeader(HDR_AUTHORIZATION), UPLOAD, id)) {
       sendError(rsp, HttpStatus.SC_UNAUTHORIZED,
           MessageFormat.format(LfsServerText.get().failedToCalcSignature,
diff --git a/src/test/java/com/googlesource/gerrit/plugins/lfs/LfsAuthTokenTest.java b/src/test/java/com/googlesource/gerrit/plugins/lfs/LfsAuthTokenTest.java
new file mode 100644
index 0000000..5b5c532
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/lfs/LfsAuthTokenTest.java
@@ -0,0 +1,116 @@
+// Copyright (C) 2017 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.lfs;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.googlesource.gerrit.plugins.lfs.LfsAuthToken.ISO;
+import static com.googlesource.gerrit.plugins.lfs.LfsAuthToken.Verifier.onTime;
+
+import com.google.common.base.Optional;
+
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+import org.junit.Test;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class LfsAuthTokenTest {
+  private final LfsCipher cipher = new LfsCipher();
+
+  @Test
+  public void testExpiredTime() throws Exception {
+    DateTime now = now();
+    // test that even 1ms expiration is enough
+    assertThat(onTime(ISO.print(now.minusMillis(1)))).isFalse();
+  }
+
+  @Test
+  public void testOnTime() throws Exception {
+    DateTime now = now();
+    // if there is at least 1ms before there is no timeout
+    assertThat(onTime(ISO.print(now.plusMillis(1)))).isTrue();
+  }
+
+  @Test
+  public void testTokenSerializationDeserialization() throws Exception {
+    TestTokenProessor processor = new TestTokenProessor(cipher);
+    TestToken token = new TestToken(0);
+    String serialized = processor.serialize(token);
+
+    assertThat(serialized).isNotEmpty();
+
+    Optional<TestToken> deserialized = processor.deserialize(serialized);
+    assertThat(deserialized.isPresent()).isTrue();
+    assertThat(token.expiresAt).isEqualTo(deserialized.get().expiresAt);
+  }
+
+  @Test
+  public void testTokenOnTime() throws Exception {
+    TestToken token = new TestToken(1);
+    TestTokenVerifier verifier = new TestTokenVerifier(token);
+    assertThat(verifier.verify()).isTrue();
+  }
+
+  @Test
+  public void testTokenExpired() throws Exception {
+    TestToken token = new TestToken(-1);
+    TestTokenVerifier verifier = new TestTokenVerifier(token);
+    assertThat(verifier.verify()).isFalse();
+  }
+
+  private DateTime now() {
+    return DateTime.now().toDateTime(DateTimeZone.UTC);
+  }
+
+  private class TestToken extends LfsAuthToken {
+    TestToken(int expirationSeconds) {
+      super(expirationSeconds);
+    }
+
+    TestToken(String expiresAt) {
+      super(expiresAt);
+    }
+  }
+
+  private class TestTokenProessor extends LfsAuthToken.Processor<TestToken> {
+    TestTokenProessor(LfsCipher cipher) {
+      super(cipher);
+    }
+
+    @Override
+    protected List<String> getValues(TestToken token) {
+      List<String> values = new ArrayList<>(2);
+      values.add(token.expiresAt);
+      return values;
+    }
+
+    @Override
+    protected Optional<TestToken> createToken(List<String> values) {
+      return Optional.of(new TestToken(values.get(0)));
+    }
+  }
+
+  private class TestTokenVerifier extends LfsAuthToken.Verifier<TestToken> {
+    protected TestTokenVerifier(TestToken token) {
+      super(token);
+    }
+
+    @Override
+    protected boolean verifyTokenValues() {
+      return true;
+    }
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/lfs/LfsCipherTest.java b/src/test/java/com/googlesource/gerrit/plugins/lfs/LfsCipherTest.java
new file mode 100644
index 0000000..5f51f4a
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/lfs/LfsCipherTest.java
@@ -0,0 +1,58 @@
+// Copyright (C) 2017 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.lfs;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Optional;
+
+import org.junit.Test;
+
+public class LfsCipherTest {
+  private final LfsCipher cipher = new LfsCipher();
+
+  @Test
+  public void testCipherTextIsDifferentThanInput() throws Exception {
+    String plain = "plain text";
+    String encrypted = cipher.encrypt(plain);
+    assertThat(encrypted).isNotEmpty();
+    assertThat(encrypted).isNotEqualTo(plain);
+  }
+
+  @Test
+  public void testVerifyDecodeAgainstEncodedInput() throws Exception {
+    String plain = "plain text";
+    String encrypted = cipher.encrypt(plain);
+    Optional<String> decrypted = cipher.decrypt(encrypted);
+    assertThat(decrypted.isPresent()).isTrue();
+    assertThat(decrypted.get()).isEqualTo(plain);
+  }
+
+  @Test
+  public void testVerifyDecodeAgainstInvalidInput() throws Exception {
+    String plain = "plain text";
+    String encrypted = cipher.encrypt(plain);
+    // there is a chance that two first chars in token are the same
+    // in such case re-generate the token
+    while(encrypted.charAt(0) == encrypted.charAt(1)) {
+      encrypted = cipher.encrypt(plain);
+    }
+
+    Optional<String> decrypted = cipher.decrypt(encrypted.substring(1, 2)
+        + encrypted.substring(0, 1) + encrypted.substring(2));
+    assertThat(decrypted.isPresent()).isTrue();
+    assertThat(decrypted.get()).isNotEqualTo(plain);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizerTest.java b/src/test/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizerTest.java
index 8cab1a3..2e99ae5 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizerTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/lfs/fs/LfsFsRequestAuthorizerTest.java
@@ -15,74 +15,36 @@
 package com.googlesource.gerrit.plugins.lfs.fs;
 
 import static com.google.common.truth.Truth.assertThat;
-import static com.googlesource.gerrit.plugins.lfs.fs.LfsFsRequestAuthorizer.DATE_TIME;
 import static org.eclipse.jgit.lfs.lib.LongObjectId.zeroId;
 
+import com.googlesource.gerrit.plugins.lfs.LfsCipher;
+import com.googlesource.gerrit.plugins.lfs.fs.LfsFsRequestAuthorizer.AuthInfo;
+import com.googlesource.gerrit.plugins.lfs.fs.LfsFsRequestAuthorizer.Processor;
+
 import org.eclipse.jgit.lfs.lib.LongObjectId;
-import org.joda.time.DateTime;
-import org.joda.time.DateTimeZone;
 import org.junit.Test;
 
 public class LfsFsRequestAuthorizerTest {
-  private final LfsFsRequestAuthorizer auth = new LfsFsRequestAuthorizer();
+  private final LfsFsRequestAuthorizer auth =
+      new LfsFsRequestAuthorizer(new Processor(new LfsCipher()));
 
   @Test
-  public void testExpiredTime() throws Exception {
-    DateTime now = DateTime.now().toDateTime(DateTimeZone.UTC);
-    // test that even 1s expiration is enough
-    assertThat(auth.onTime(DATE_TIME.print(now.minusSeconds(1)), "o", "id"))
-            .isFalse();
-  }
-
-  @Test
-  public void testOnTime() throws Exception {
-    DateTime now = DateTime.now().toDateTime(DateTimeZone.UTC);
-    // if there is at least 1s before there is no timeout
-    assertThat(auth.onTime(DATE_TIME.print(now.plusSeconds(1)), "o", "id"))
-            .isTrue();
-  }
-
-  @Test
-  public void testVerifyAgainstMissingToken() throws Exception {
-    assertThat(auth.verifyAgainstToken("", "o", zeroId())).isFalse();
-    assertThat(auth.verifyAgainstToken(null, "o", zeroId())).isFalse();
-  }
-
-  @Test
-  public void testVerifyAgainstToken() throws Exception {
-    String token = auth.generateToken("o", zeroId(), 1);
-    assertThat(auth.verifyAgainstToken(token, "o", zeroId())).isTrue();
-  }
-
-  @Test
-  public void testVerifyAgainstInvalidToken() throws Exception {
-    String token = auth.generateToken("o", zeroId(), 1);
-    // replace 1st and 2nd token letters with each other
-    assertThat(auth.verifyAgainstToken(
-        token.substring(1, 2) + token.substring(0, 1) + token.substring(2), "o",
-        zeroId())).isFalse();
+  public void testVerifyAuthInfo() throws Exception {
+    AuthInfo info = auth.generateAuthInfo("o", zeroId(), 1);
+    assertThat(auth.verifyAuthInfo(info.authToken, "o", zeroId())).isTrue();
   }
 
   @Test
   public void testVerifyAgainstDifferentOperation() throws Exception {
-    String token = auth.generateToken("o", zeroId(), 1);
-    assertThat(auth.verifyAgainstToken(token, "p", zeroId())).isFalse();
+    AuthInfo info = auth.generateAuthInfo("o", zeroId(), 1);
+    assertThat(auth.verifyAuthInfo(info.authToken, "p", zeroId())).isFalse();
   }
 
   @Test
   public void testVerifyAgainstDifferentObjectId() throws Exception {
-    String token = auth.generateToken("o", zeroId(), 1);
-    assertThat(auth.verifyAgainstToken(token, "o",
-        LongObjectId.fromString(
-            "123456789012345678901234567890"
-            + "123456789012345678901234567890"
-            + "1234"))).isFalse();
-  }
-
-  @Test
-  public void testVerifyAgainstExpiredToken() throws Exception {
-    // generate already expired token
-    String token = auth.generateToken("o", zeroId(), -1);
-    assertThat(auth.verifyAgainstToken(token, "o", zeroId())).isFalse();
+    AuthInfo info = auth.generateAuthInfo("o", zeroId(), 1);
+    assertThat(auth.verifyAuthInfo(info.authToken, "o",
+        LongObjectId.fromString("123456789012345678901234567890"
+            + "123456789012345678901234567890" + "1234"))).isFalse();
   }
 }