5
\$\begingroup\$

This class is responsible for storing an API Key and Secret pair. The secret is encrypted with AES in CFB mode, using a key derived from a passphrase, a random salt and a number of rounds of SHA1.

An additional hash of the plaintext and the salt is stored to allow for detecting incorrect passphrase entry.

This is tested and working, but I would love feedback on correctness or improvements, especially areas where the code can be simplified or replaced with built-in functionality I am unaware of.

The key id is a string UUID format, and the secret is provided as a base-64 encoded string.

package com.bitgrind.mtgox;

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Properties;

import javax.crypto.Cipher;
import javax.crypto.Mac;
import javax.crypto.SecretKey;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;

import com.google.common.base.Charsets;
import com.google.common.hash.HashCode;
import com.google.common.hash.HashFunction;
import com.google.common.hash.Hashing;
import com.google.common.io.BaseEncoding;
import com.google.common.io.Closer;
import com.google.common.primitives.Bytes;

public class ApiKey {
  private static final String PROP_HASH = "hash";
  private static final String PROP_SALT = "salt";
  private static final String PROP_SECRET = "secret";
  private static final String PROP_KEY = "key";

  private static final BaseEncoding BASE64 = BaseEncoding.base64();
  private static final BaseEncoding HEX = BaseEncoding.base16().lowerCase();
  private static final byte[] IV = new byte[] {
    0x10, 0x10, 0x01, 0x04, 0x01, 0x01, 0x01, 0x02, 
    0x14, 0x1c, 0x11, 0x44, 0x21, 0x4a, 0x22, 0x31 
  };
  private static final int SALT_LENGTH = 16;
  private static final int HASH_ROUNDS = 65535;
  private static final String CIPHER_PARAMS = "AES/OFB8/NOPADDING";

  private static final SecureRandom RND;
  static {
    try {
      RND = SecureRandom.getInstance("SHA1PRNG");
    } catch (NoSuchAlgorithmException e) {
      throw new AssertionError(e);
    }
  }

  private static byte[] createRandomSalt() {
    byte[] salt = new byte[SALT_LENGTH];
    RND.nextBytes(salt);
    return salt;
  }

  private static SecretKey deriveKey(byte[] salt, int iterations, String passphrase) {
    final HashFunction sha1 = Hashing.sha1();
    byte[] hash = new byte[salt.length + (sha1.bits() / 8)];
    HashCode code = sha1.hashBytes(Bytes.concat(salt, passphrase.getBytes(Charsets.UTF_8)));
    int len = code.writeBytesTo(hash, 0, hash.length);
    System.arraycopy(salt, 0, hash, len, salt.length);
    for (int i=0; i < iterations; i++) {
      code = sha1.hashBytes(hash);
      len = code.writeBytesTo(hash, 0, hash.length);
      System.arraycopy(salt, 0, hash, len, salt.length);
    }
    return new SecretKeySpec(code.asBytes(), 0, 16, "AES");
  }

  public static final ApiKeyResult read(InputStream in, String passphrase) {
    Properties p = new Properties();
    try {
      p.load(in);
      String apiKey = p.getProperty(PROP_KEY, "");
      byte[] encryptedKey = BASE64.decode(p.getProperty(PROP_SECRET, "0"));
      byte[] salt = HEX.decode(p.getProperty(PROP_SALT, "0"));
      byte[] hash = HEX.decode(p.getProperty(PROP_HASH, "0"));
      SecretKey key = deriveKey(salt, HASH_ROUNDS, passphrase);
      Cipher aes =  Cipher.getInstance(CIPHER_PARAMS);
      aes.init(Cipher.DECRYPT_MODE, key, new IvParameterSpec(IV));
      byte[] decryptedKey = aes.doFinal(encryptedKey);
      byte[] computedHash = Hashing.sha512().newHasher().putBytes(salt).putBytes(decryptedKey).hash().asBytes();
      if (!Arrays.equals(hash, computedHash)) {
        return new ApiKeyResult("Wrong passphrase");
      }
      return new ApiKeyResult(new ApiKey(apiKey, BASE64.encode(decryptedKey)));
    } catch (Exception e) {
      return new ApiKeyResult(e);
    }
  }

  private final String key;
  private final String secret;

  ApiKey(String key, String secret) {
    this.key = key;
    this.secret = secret;
  }

  public byte[] getKeyBytes() {
    return HEX.decode(key.replace("-", ""));
  }

  public byte[] signMessage(byte[] content) throws InvalidKeyException {
    Mac mac;
    try {
      mac = Mac.getInstance("HmacSHA512");
    } catch (NoSuchAlgorithmException e) {
      throw new AssertionError(e);
    }
    SecretKeySpec secretKey = new SecretKeySpec(BASE64.decode(secret), "HmacSHA512");
    mac.init(secretKey);
    return mac.doFinal(content);
  }

  public final ApiKeyResult write(String passphrase, OutputStream out) {
    Properties p = new Properties();
    try {
      byte[] salt = createRandomSalt();
      SecretKey aesKey = deriveKey(salt, HASH_ROUNDS, passphrase);
      Cipher aes =  Cipher.getInstance(CIPHER_PARAMS);
      aes.init(Cipher.ENCRYPT_MODE, aesKey, new IvParameterSpec(IV));
      byte[] decryptedBytes = BASE64.decode(secret);
      byte[] encryptedKey = aes.doFinal(decryptedBytes);
      p.put(PROP_KEY, key);
      p.put(PROP_SECRET, BASE64.encode(encryptedKey));
      p.put(PROP_SALT, HEX.encode(salt));
      p.put(PROP_HASH, Hashing.sha512().newHasher().putBytes(salt).putBytes(decryptedBytes).hash().toString());
      p.store(out, null);
      out.flush();
      return new ApiKeyResult();
    } catch (Exception e) {
      return new ApiKeyResult(e);
    }
  }
}
\$\endgroup\$

1 Answer 1

6
\$\begingroup\$
  1. key.write(passphrase, new FileOutputStream(filename));
    

    I see that the stream is flushed in the write method but it will be cleaner to close it too.

    (Guideline 1-2: Release resources in all cases in the Secure Coding Guidelines for the Java Programming Language, Version 4.0).

  2. return new SecretKeySpec(code.asBytes(), 0, 16, "AES");
    

    I'd create local variables for 0 and 16. What are they supposed to be? Currently they're magic numbers. It would be readable not to force readers to check the implementation or javadoc to figure out their purpose.

    The code has them issue with the null parameter here:

    p.store(out, null);
    
  3. HashCode code = sha1.hashBytes(Bytes.concat(salt, passphrase.getBytes(Charsets.UTF_8)));
    int len = code.writeBytesTo(hash, 0, hash.length);
    System.arraycopy(salt, 0, hash, len, salt.length);
    for (int i = 0; i < iterations; i++) {
        code = sha1.hashBytes(hash);
        len = code.writeBytesTo(hash, 0, hash.length);
        System.arraycopy(salt, 0, hash, len, salt.length);
    }
    

    The code and len variables are used for two different purposes. It would be easier to read if they were separate variables. (Anyway, len is too general, whose length is it? I'd put that information into the name.)

    The same is true here for ciphertext:

    byte[] ciphertext = BASE64.omitPadding().decode(p.getProperty(PROP_ENC_KEY, "0"));
    byte[] salt = Arrays.copyOfRange(ciphertext, 0, SALT_LENGTH);
    ciphertext = Arrays.copyOfRange(ciphertext, SALT_LENGTH, ciphertext.length);
    

    I guess the first array should be called saltedCiphertext.

  4. The read method does more than one thing (as well as the write):

    • handles the Properties object
    • checks the checksum
    • parse input data

    I'd put these functions to separate methods (or classes). Actually, the class is also responsible for more than one thing which violates the single responsibility principle.

  5. private final String key;
    final String secret;
    

    I guess secret should be also private here.

  6. ApiKey(String key, String secret) {
        this.key = key;
        this.secret = secret;
    }
    

    It's a good practice to validate input parameters. Does it make sense to create an ApiKey instance with null or empty string key or secret? If not, check it and throw a NullPointerException or IllegalArgumentException than postponing the error which is harder to debug.

    If someone creates an ApiKey with null key getKeyBytes() will throw an NPE. The stacktrace of this NPE will contain a stacktrace but it won't be the stacktrace of the real error (the consructor call).

    (Effective Java, Second Edition, Item 38: Check parameters for validity; The Pragmatic Programmer: From Journeyman to Master by Andrew Hunt and David Thomas: Dead Programs Tell No Lies.)

  7. HmacSHA512 could be a named constant, it is used multiple times.

    try {
        mac = Mac.getInstance("HmacSHA512");
    } catch (NoSuchAlgorithmException e) {
        throw new AssertionError(e);
    }
    SecretKeySpec secretKey = new SecretKeySpec(BASE64.decode(secret), "HmacSHA512");
    
  8. I would use longer variable names than p or in. Longer names would make the code more readable since readers don't have to decode the abbreviations every time when they write/maintain the code and don't have to guess which abbreviation the author uses.

    (Clean Code by Robert C. Martin, Avoid Mental Mapping, p25)

  9. byte[] secret;
    secret = Arrays.copyOfRange(plaintext, 0, plaintext.length - 4);
    byte[] checksum = Arrays.copyOfRange(plaintext, plaintext.length - 4, plaintext.length);
    

    I'd create a local variable for plaintext.length - 4. I think the following tells more about the intent of code:

    int checksumLength = 4;
    int secretLength = plaintext.length - checksumLength;
    byte[] secret = Arrays.copyOfRange(plaintext, 0, secretLength);
    byte[] checksum = Arrays.copyOfRange(plaintext, secretLength, plaintext.length);
    

    It even deserves a separate method:

    private static byte[] getVerifiedSecret(final byte[] plaintext) throws ApiKeyException {
        final int checksumLength = 4;
        final int secretLength = plaintext.length - checksumLength;
        final byte[] secret = Arrays.copyOfRange(plaintext, 0, secretLength);
        final byte[] checksum = Arrays.copyOfRange(plaintext, secretLength, plaintext.length);
        if (!Arrays.equals(CRC32.hashBytes(secret).asBytes(), checksum)) {
            throw new ApiKeyException("Wrong passphrase");
        }
        return secret;
    }
    
  10. The main method should be in a separate class (because of the single responsibility principle again) and it should catch the exceptions. Instead of printing the full stacktrace (which is hardly useful for a user), print only the message and log (with a logger) the details to a log file.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.