content: /*
   * Universal Password Manager
   * Copyright (C) 2005-2013 Adrian Smith
   *
   * This file is part of Universal Password Manager.
   *   
   * Universal Password Manager is free software; you can redistribute it and/or modify
   * it under the terms of the GNU General Public License as published by
   * the Free Software Foundation; either version 2 of the License, or
   * (at your option) any later version.
   *
   * Universal Password Manager is distributed in the hope that it will be useful,
   * but WITHOUT ANY WARRANTY; without even the implied warranty of
   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
   * GNU General Public License for more details.
   *
   * You should have received a copy of the GNU General Public License
   * along with Universal Password Manager; if not, write to the Free Software
   * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
   */
  package com._17od.upm.database;
  
  import java.io.ByteArrayInputStream;
  import java.io.ByteArrayOutputStream;
  import java.io.EOFException;
  import java.io.File;
  import java.io.FileInputStream;
  import java.io.FileOutputStream;
  import java.io.IOException;
  import java.io.InputStream;
  import java.nio.charset.Charset;
  import java.util.Arrays;
  import java.util.HashMap;
  import java.util.Iterator;
  
  import com._17od.upm.crypto.CryptoException;
  import com._17od.upm.crypto.DESDecryptionService;
  import com._17od.upm.crypto.EncryptionService;
  import com._17od.upm.crypto.InvalidPasswordException;
  import com._17od.upm.util.Util;
  
  /**
   * This factory is used to load or create a PasswordDatabase. Different versions
   * of the database need to be loaded slightly differently so this class takes
   * care of those differences.
   * 
   * Database versions and formats. The items between [] brackets are encrypted.
   *   3     >> MAGIC_NUMBER DB_VERSION SALT [DB_REVISION DB_OPTIONS ACCOUNTS]
   *      (all strings are encoded using UTF-8)
   *   2     >> MAGIC_NUMBER DB_VERSION SALT [DB_REVISION DB_OPTIONS ACCOUNTS]
   *   1.1.0 >> SALT [DB_HEADER DB_REVISION DB_OPTIONS ACCOUNTS]
   *   1.0.0 >> SALT [DB_HEADER ACCOUNTS]
   * 
   *   DB_VERSION = The structural version of the database
   *   SALT = The salt used to mix with the user password to create the key
   *   DB_HEADER = Was used to store the structural version of the database (pre version 2)
   *   DB_OPTIONS = Options relating to the database
   *   ACCOUNTS = The account information
   *   
   *   From version 2 the db version is stored unencrypted at the start of the file.
   *   This allows for cryptographic changes in the database structure. Before this
   *   we had to know how to unencrypt the database before we could find out the version number.
   */
  public class PasswordDatabasePersistence {
  
      private static final String FILE_HEADER = "UPM";
      private static final int DB_VERSION = 3;
  
      private EncryptionService encryptionService;
  
      /**
       * Used when we have a password and we want to get an instance of the class
       * so that we can call load(File, char[])  
       */
      public PasswordDatabasePersistence() {
      }
  
      /**
       * Used when we want to create a new database with the given password
       * @param password
       * @throws CryptoException
       */
      public PasswordDatabasePersistence(char[] password) throws CryptoException {
          encryptionService = new EncryptionService(password);
      }
  
      public PasswordDatabase load(File databaseFile) throws InvalidPasswordException, ProblemReadingDatabaseFile, IOException {
  
          byte[] fullDatabase = readFile(databaseFile);
  
          // Check the database is a minimum length
          if (fullDatabase.length < EncryptionService.SALT_LENGTH) {
              throw new ProblemReadingDatabaseFile("This file doesn't appear to be a UPM password database");
          }
  
          PasswordDatabase passwordDatabase = null;
          ByteArrayInputStream is = null;
          Revision revision = null;
          DatabaseOptions dbOptions = null;
          HashMap accounts = null;
          Charset charset = Charset.forName("UTF-8");
  
          // Ensure this is a real UPM database by checking for the existence of 
          // the string "UPM" at the start of the file
          byte[] header = new byte[FILE_HEADER.getBytes().length];
          System.arraycopy(fullDatabase, 0, header, 0, header.length);
          if (Arrays.equals(header, FILE_HEADER.getBytes())) {
  
              // Calculate the positions of each item in the file
              int dbVersionPos      = header.length;
              int saltPos           = dbVersionPos + 1;
              int encryptedBytesPos = saltPos + EncryptionService.SALT_LENGTH;
  
              // Get the database version 
              byte dbVersion = fullDatabase[dbVersionPos];
  
              if (dbVersion == 2 || dbVersion == 3) {
                  byte[] salt = new byte[EncryptionService.SALT_LENGTH];
                  System.arraycopy(fullDatabase, saltPos, salt, 0, EncryptionService.SALT_LENGTH);
                  int encryptedBytesLength = fullDatabase.length - encryptedBytesPos;
                  byte[] encryptedBytes = new byte[encryptedBytesLength]; 
                  System.arraycopy(fullDatabase, encryptedBytesPos, encryptedBytes, 0, encryptedBytesLength);
  
                  // From version 3 onwards Strings in AccountInformation are
                  // encoded using UTF-8. To ensure we can still open older dbs
                  // we default back to the then character set, the system default
                  if (dbVersion < 3) {
                      charset = Util.defaultCharset();
                  }
  
                  //Attempt to decrypt the database information
                  byte[] decryptedBytes;
                  try {
                      decryptedBytes = encryptionService.decrypt(encryptedBytes);
                  } catch (CryptoException e1) {
                      throw new InvalidPasswordException();
                  }
  
                  //If we've got here then the database was successfully decrypted 
                  is = new ByteArrayInputStream(decryptedBytes);
                  try {
                      revision = new Revision(is);
                      dbOptions = new DatabaseOptions(is);
      
                      // Read the remainder of the database in now
                      accounts = new HashMap();
                      try {
                          while (true) { //keep loading accounts until an EOFException is thrown
                              AccountInformation ai = new AccountInformation(is, charset);
                              accounts.put(ai.getAccountName(), ai);
                          }
                      } catch (EOFException e) {
                          //just means we hit eof
                      }
                      is.close();
                  } catch (IOException e) {
                      throw new ProblemReadingDatabaseFile(e.getMessage(), e);
                  }
  
                  passwordDatabase = new PasswordDatabase(revision, dbOptions, accounts, databaseFile);
  
              } else {
                  throw new ProblemReadingDatabaseFile("Don't know how to handle database version [" + dbVersion + "]");
              }
  
          } else {
              
              // This might be an old database (pre version 2).
              // By throwing InvalidPasswordException the calling method can ask
              // the user for the password so that the load(File, char[]) method
              // can be called. That method knows how to load old versions of the
              // db
              throw new InvalidPasswordException();
          }
                  
          return passwordDatabase;
  
      }
  
      public PasswordDatabase load(File databaseFile, char[] password) throws IOException, ProblemReadingDatabaseFile, InvalidPasswordException, CryptoException {
  
          byte[] fullDatabase;
          fullDatabase = readFile(databaseFile);
  
          // Check the database is a minimum length
          if (fullDatabase.length < EncryptionService.SALT_LENGTH) {
              throw new ProblemReadingDatabaseFile("This file doesn't appear to be a UPM password database");
          }
  
          ByteArrayInputStream is = null;
          Revision revision = null;
          DatabaseOptions dbOptions = null;
          Charset charset = Charset.forName("UTF-8");
  
          // Ensure this is a real UPM database by checking for the existence of 
          // the string "UPM" at the start of the file
          byte[] header = new byte[FILE_HEADER.getBytes().length];
          System.arraycopy(fullDatabase, 0, header, 0, header.length);
          if (Arrays.equals(header, FILE_HEADER.getBytes())) {
  
              // Calculate the positions of each item in the file
              int dbVersionPos      = header.length;
              int saltPos           = dbVersionPos + 1;
              int encryptedBytesPos = saltPos + EncryptionService.SALT_LENGTH;
  
              // Get the database version 
              byte dbVersion = fullDatabase[dbVersionPos];
  
              if (dbVersion == 2 || dbVersion == 3) {
                  byte[] salt = new byte[EncryptionService.SALT_LENGTH];
                  System.arraycopy(fullDatabase, saltPos, salt, 0, EncryptionService.SALT_LENGTH);
                  int encryptedBytesLength = fullDatabase.length - encryptedBytesPos;
                  byte[] encryptedBytes = new byte[encryptedBytesLength]; 
                  System.arraycopy(fullDatabase, encryptedBytesPos, encryptedBytes, 0, encryptedBytesLength);
  
                  // From version 3 onwards Strings in AccountInformation are
                  // encoded using UTF-8. To ensure we can still open older dbs
                  // we default back to the then character set, the system default
                  if (dbVersion < 3) {
                      charset = Util.defaultCharset();
                  }
  
                  //Attempt to decrypt the database information
                  encryptionService = new EncryptionService(password, salt);
                  byte[] decryptedBytes;
                  try {
                      decryptedBytes = encryptionService.decrypt(encryptedBytes);
                  } catch (CryptoException e) {
                      throw new InvalidPasswordException();
                  }
  
                  //If we've got here then the database was successfully decrypted 
                  is = new ByteArrayInputStream(decryptedBytes);
                  revision = new Revision(is);
                  dbOptions = new DatabaseOptions(is);
              } else {
                  throw new ProblemReadingDatabaseFile("Don't know how to handle database version [" + dbVersion + "]");
              }
  
          } else {
              
              // This might be an old database (pre version 2) so try loading it using the old database format
              
              // Check the database is a minimum length
              if (fullDatabase.length < EncryptionService.SALT_LENGTH) {
                  throw new ProblemReadingDatabaseFile("This file doesn't appear to be a UPM password database");
              }
              
              //Split up the salt and encrypted bytes
              byte[] salt = new byte[EncryptionService.SALT_LENGTH];
              System.arraycopy(fullDatabase, 0, salt, 0, EncryptionService.SALT_LENGTH);
              int encryptedBytesLength = fullDatabase.length - EncryptionService.SALT_LENGTH;
              byte[] encryptedBytes = new byte[encryptedBytesLength]; 
              System.arraycopy(fullDatabase, EncryptionService.SALT_LENGTH, encryptedBytes, 0, encryptedBytesLength);
  
              byte[] decryptedBytes = null;
              //Attempt to decrypt the database information
              try {
                  decryptedBytes = DESDecryptionService.decrypt(password, salt, encryptedBytes);
              } catch (CryptoException e) {
                  throw new InvalidPasswordException();
              }
  
              //We'll get to here if the password was correct so load up the decryped byte
              is = new ByteArrayInputStream(decryptedBytes);
              DatabaseHeader dh = new DatabaseHeader(is);
  
              // At this point we'll check to see what version the database is and load it accordingly
              if (dh.getVersion().equals("1.1.0")) {
                  // Version 1.1.0 introduced a revision number & database options so read that in now
                  revision = new Revision(is);
                  dbOptions = new DatabaseOptions(is);
              } else if (dh.getVersion().equals("1.0.0")) {
                  revision = new Revision();
                  dbOptions = new DatabaseOptions();
              } else {
                  throw new ProblemReadingDatabaseFile("Don't know how to handle database version [" + dh.getVersion() + "]");
              }
  
              // Initialise the EncryptionService so that it's ready for the "save" operation
              encryptionService = new EncryptionService(password);
  
          }
          
          // Read the remainder of the database in now
          HashMap accounts = new HashMap();
          try {
              while (true) { //keep loading accounts until an EOFException is thrown
                  AccountInformation ai = new AccountInformation(is, charset);
                  accounts.put(ai.getAccountName(), ai);
              }
          } catch (EOFException e) {
              //just means we hit eof
          }
          is.close();
  
          PasswordDatabase passwordDatabase = new PasswordDatabase(revision, dbOptions, accounts, databaseFile);
          
          return passwordDatabase;
  
      }
  
      public void save(PasswordDatabase database) throws IOException, CryptoException {
          ByteArrayOutputStream os = new ByteArrayOutputStream();
          
          // Flatpack the database revision and options
          database.getRevisionObj().increment();
          database.getRevisionObj().flatPack(os);
          database.getDbOptions().flatPack(os);
  
          // Flatpack the accounts
          Iterator it = database.getAccountsHash().values().iterator();
          while (it.hasNext()) {
              AccountInformation ai = (AccountInformation) it.next();
              ai.flatPack(os);
          }
          os.close();
          byte[] dataToEncrypt = os.toByteArray();
  
          //Now encrypt the database data
          byte[] encryptedData = encryptionService.encrypt(dataToEncrypt);
          
          //Write the salt and the encrypted data out to the database file
          FileOutputStream fos = new FileOutputStream(database.getDatabaseFile());
          fos.write(FILE_HEADER.getBytes());
          fos.write(DB_VERSION);
          fos.write(encryptionService.getSalt());
          fos.write(encryptedData);
          fos.close();
      }
  
      public EncryptionService getEncryptionService() {
          return encryptionService;
      }
  
      private byte[] readFile(File file) throws IOException {
          InputStream is;
          try {
              is = new FileInputStream(file);
          } catch (IOException e) {
              throw new IOException("There was a problem with opening the file", e);
          }
      
          // Create the byte array to hold the data
          byte[] bytes = new byte[(int) file.length()];
      
          // Read in the bytes
          int offset = 0;
          int numRead = 0;
          
          try {
              while (offset < bytes.length
                      && (numRead=is.read(bytes, offset, bytes.length-offset)) >= 0) {
                  offset += numRead;
              }
      
              // Ensure all the bytes have been read in
              if (offset < bytes.length) {
                  throw new IOException("Could not completely read file " + file.getName());
              }
          } finally {
              is.close();
          }
  
          return bytes;
      }
  
  }
  