/* * Universal Password Manager * Copyright (c) 2010-2011 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.u17od.upm.database; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.EOFException; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.nio.charset.Charset; import java.security.GeneralSecurityException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import javax.crypto.BadPaddingException; import javax.crypto.IllegalBlockSizeException; import javax.crypto.SecretKey; import com.u17od.upm.crypto.DESDecryptionService; import com.u17od.upm.crypto.EncryptionService; import com.u17od.upm.crypto.InvalidPasswordException; import com.u17od.upm.util.Util; /** * This class represents the main interface to a password database. * All interaction with the database file is done using this class. * * 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 because beforehand * we had to know how to unencrypt the database before we could find out the version number. */ public class PasswordDatabase { private static final int DB_VERSION = 3; private static final String FILE_HEADER = "UPM"; private File databaseFile; private Revision revision; private DatabaseOptions dbOptions; private HashMap<String, AccountInformation> accounts; private EncryptionService encryptionService; public PasswordDatabase(File dbFile, SecretKey secretKey) throws IOException, GeneralSecurityException, ProblemReadingDatabaseFile, InvalidPasswordException { databaseFile = dbFile; load(secretKey); } public PasswordDatabase(File dbFile, char[] password) throws IOException, GeneralSecurityException, ProblemReadingDatabaseFile, InvalidPasswordException { this(dbFile, password, false); } public PasswordDatabase(File dbFile, char[] password, boolean overwrite) throws IOException, GeneralSecurityException, ProblemReadingDatabaseFile, InvalidPasswordException { databaseFile = dbFile; //Either create a new file (if it exists and overwrite == true OR it doesn't exist) or open the existing file if ((databaseFile.exists() && overwrite == true) || !databaseFile.exists()) { databaseFile.delete(); databaseFile.createNewFile(); revision = new Revision(); dbOptions = new DatabaseOptions(); accounts = new HashMap<String, AccountInformation>(); encryptionService = new EncryptionService(password); } else { SecretKey secretKey = EncryptionService.createSecretKey(password); load(secretKey); } } public void changePassword(char[] password) throws GeneralSecurityException { encryptionService = new EncryptionService(password); } private void load(SecretKey secretKey) throws IOException, GeneralSecurityException, ProblemReadingDatabaseFile, InvalidPasswordException { //Read in the encrypted bytes byte[] fullDatabase = Util.getBytesFromFile(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; Charset charset = Charset.forName("UTF-8"); // Ensure this is a real UPM database by checking for the existance 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(secretKey, salt); byte[] decryptedBytes = encryptionService.decrypt(encryptedBytes); //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; try { //Attempt to decrypt the database information decryptedBytes = DESDecryptionService.decrypt(secretKey, salt, encryptedBytes); } catch (IllegalBlockSizeException e) { throw new ProblemReadingDatabaseFile("Either your password is incorrect or this file isn't a UPM password database"); } // Create the encryption for use later in the save() method encryptionService = new EncryptionService(secretKey, salt); //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() + "]"); } } // Read the remainder of the database in now accounts = new HashMap<String, AccountInformation>(); try { while (true) { //keep loading accounts until an EOFException is thrown AccountInformation ai = new AccountInformation(is, charset); addAccount(ai); } } catch (EOFException e) { //just means we hit eof } is.close(); } public void addAccount(AccountInformation ai) { accounts.put(ai.getAccountName(), ai); } public void deleteAccount(String accountName) { accounts.remove(accountName); } public AccountInformation getAccount(String name) { return accounts.get(name); } public void save() throws IOException, IllegalBlockSizeException, BadPaddingException { ByteArrayOutputStream os = new ByteArrayOutputStream(); // Flatpack the database revision and options revision.increment(); revision.flatPack(os); dbOptions.flatPack(os); // Flatpack the accounts Iterator<AccountInformation> it = accounts.values().iterator(); while (it.hasNext()) { AccountInformation ai = 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 a temporary file File tempFile = File.createTempFile("upmdb", null); FileOutputStream fos = new FileOutputStream(tempFile); fos.write(FILE_HEADER.getBytes()); fos.write(DB_VERSION); fos.write(encryptionService.getSalt()); fos.write(encryptedData); fos.close(); // Rename the tempfile to the real database file // The reason for this is to protect against the write thread being // terminated thus corrupting the file. tempFile.renameTo(databaseFile); } public ArrayList<AccountInformation> getAccounts() { return new ArrayList<AccountInformation>(accounts.values()); } public ArrayList<String> getAccountNames() { ArrayList<String> accountNames = new ArrayList<String>(accounts.keySet()); Collections.sort(accountNames, String.CASE_INSENSITIVE_ORDER); return accountNames; } public File getDatabaseFile() { return databaseFile; } /** * There are times when we decrypt a temp version of the database file, * e.g. when we download a db during sync. If we end up making this temp db * our permanent db then we don't want to have to decrypt it again. In this * instance what we do is overwrite the main db file with the temp downloaded * one and then repoint this PassswordDatabase at the main db file. * @param file */ public void setDatabaseFile(File file) { databaseFile = file; } public DatabaseOptions getDbOptions() { return dbOptions; } public int getRevision() { return revision.getRevision(); } /** * Check if the given bytes represent a password database by examining the * header bytes for the UPM magic number. * @param data * @return */ public static boolean isPasswordDatabase(byte[] data) { boolean isPasswordDatabase = false; // Extract the header bytes byte[] headerBytes = new byte[FILE_HEADER.getBytes().length]; if (data != null && data.length > headerBytes.length) { // Check if the first n bytes are what we expect in a UPM password // database for (int i=0; i<headerBytes.length; i++) { headerBytes[i] = data[i]; } if (Arrays.equals(headerBytes, FILE_HEADER.getBytes())) { isPasswordDatabase = true; } } return isPasswordDatabase; } public static boolean isPasswordDatabase(File file) throws IOException { boolean isPasswordDatabase = false; // Extract the header bytes byte[] headerBytes = new byte[FILE_HEADER.getBytes().length]; if (file != null && file.length() > headerBytes.length) { byte[] data = Util.getBytesFromFile(file, headerBytes.length + 1); return isPasswordDatabase(data); } return isPasswordDatabase; } public EncryptionService getEncryptionService () { return encryptionService; } }