/*
* 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;
}
}