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