/**
 * Copyright 2011 Caleb Richardson
 * 
 * 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.outerspacecat.util;

import com.google.common.base.Preconditions;
import com.google.common.primitives.Bytes;
import com.google.common.primitives.Longs;
import java.io.IOException;
import java.time.Instant;
import java.util.regex.Pattern;

/**
 * Defines utility methods for working with HTTP cookies.
 * 
 * @author Caleb Richardson
 */
public final class Cookies
{
  private final static Pattern HEX_PATTERN =
      Pattern.compile("[0123456789abcdef]*");

  private Cookies ()
  {
  }

  /**
   * Generates a session cookie value. Uses the algorithm described in <a
   * href="http://www.cse.msu.edu/~alexliu/publications/Cookie/cookie.pdf">A
   * Secure Cookie Protocol</a>.
   * <p>
   * The paper recommends using an SSL session key for the {@code sessionKey}
   * parameter, another possibility is to use a client IP address. Since both of
   * these approaches have flaws (SSL renegotiation and NAT respectively), an
   * empty array may be used if the risk of replay attacks is acceptable. Replay
   * attack risk may also be mitigated by expiring the cookie after a certain
   * amount of time.
   * 
   * @param id a user identifier. Must be non {@code null}.
   * @param data optional data to be stored in the cookie. The data will be
   *        encrypted using {@code key}. Must be non {@code null}, may be empty.
   * @param sessionKey optional information to prevent replay attacks. Must be
   *        non {@code null}, may be empty.
   * @param key a secret server key. Must be non {@code null}, may be any
   *        length.
   * @return a session cookie value. Never {@code null}.
   */
  public static String generateSessionCookie ( final byte[] id,
      final byte[] data, final byte[] sessionKey, final byte[] key )
  {
    Preconditions.checkNotNull(id, "id required");
    Preconditions.checkNotNull(data, "data required");
    Preconditions.checkNotNull(sessionKey, "sessionKey required");
    Preconditions.checkNotNull(key, "key required");

    byte[] created = Longs.toByteArray(System.currentTimeMillis());

    byte[] k = Utils.hmacSha1(Bytes.concat(id, created), key);

    byte[] hmac =
        Utils.hmacSha1(Bytes.concat(id, created, data, sessionKey), k);

    Tuple2<byte[], byte[]> aesPair = Utils.encryptAes128Cbc(data, Utils.md5(k));

    StringBuilder sb =
        new StringBuilder(6 + id.length * 2 + created.length * 2 + 32 +
            aesPair.getB().length * 2 + hmac.length * 2);

    sb.append("1$").append(Utils.toHex(id)).append('$').append(
        Utils.toHex(created)).append('$').append(Utils.toHex(aesPair.getA())).append(
        '$').append(Utils.toHex(aesPair.getB())).append('$').append(
        Utils.toHex(hmac));

    return sb.toString();
  }

  /**
   * Parses a session cookie previously generated by
   * {@link #generateSessionCookie(byte[], byte[], byte[], byte[])}.
   * 
   * @param key the key that was used to create the cookie. Must be non
   *        {@code null}.
   * @param sessionKey the session key that was used to create the cookie. Must
   *        be non {@code null}.
   * @param cookie the cookie value to parse. Must be non {@code null}.
   * @return a tuple containing the id and data used to create the cookie, along
   *         with a timestamp of when the cookie was created. Never {@code null}
   *         , all tuple values are non {@code null}.
   * @throws IOException if {@code cookie} is not a valid cookie or if it was
   *         not created using {@code key} and {@code sessionKey}.
   */
  public static Tuple3<byte[], byte[], Instant> parseSessionCookie (
      final byte[] key, final byte[] sessionKey, final CharSequence cookie )
    throws IOException
  {
    Preconditions.checkNotNull(key, "key required");
    Preconditions.checkNotNull(sessionKey, "sessionKey required");
    Preconditions.checkNotNull(cookie, "cookie required");

    String[] parts = cookie.toString().split("\\$");
    if ( parts[0].equals("1") ) {
      if ( parts.length != 6 )
        throw new IllegalArgumentException("invalid cookie: " + cookie);

      if ( parts[1].length() % 2 != 0 ||
          !HEX_PATTERN.matcher(parts[1]).matches() || parts[2].length() != 16 ||
          !HEX_PATTERN.matcher(parts[2]).matches() || parts[3].length() != 32 ||
          !HEX_PATTERN.matcher(parts[3]).matches() ||
          parts[4].length() % 2 != 0 ||
          !HEX_PATTERN.matcher(parts[4]).matches() ||
          parts[5].length() % 2 != 0 ||
          !HEX_PATTERN.matcher(parts[5]).matches() )
        throw new IOException("invalid cookie: " + cookie);

      byte[] id = Utils.fromHex(parts[1].toCharArray());

      byte[] created = Utils.fromHex(parts[2].toCharArray());

      byte[] k = Utils.hmacSha1(Bytes.concat(id, created), key);

      byte[] iv = Utils.fromHex(parts[3].toCharArray());

      byte[] data =
          Utils.decryptAes128Cbc(Utils.fromHex(parts[4].toCharArray()),
              Utils.md5(k), iv);

      byte[] hmac =
          Utils.hmacSha1(Bytes.concat(id, created, data, sessionKey), k);

      if ( !Utils.constantEquals(parts[5], new String(Utils.toHex(hmac))) )
        throw new IOException("cookie tampering detected: " + cookie);

      return Tuple3.of(id, data,
          Instant.ofEpochMilli(Longs.fromByteArray(created)));
    } else {
      throw new IOException("invalid cookie: " + cookie);
    }
  }
}
