// Copyright (c) 2009, Google Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package net.tawacentral.roger.secrets; import java.io.BufferedInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.FileReader; import java.io.FileWriter; import java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; import java.io.OutputStream; import java.text.MessageFormat; import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.List; import javax.crypto.Cipher; import javax.crypto.CipherInputStream; import javax.crypto.CipherOutputStream; import net.tawacentral.roger.secrets.SecurityUtils.CipherInfo; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import android.app.backup.BackupAgentHelper; import android.app.backup.BackupDataInput; import android.app.backup.BackupDataOutput; import android.app.backup.FileBackupHelper; import android.content.Context; import android.content.SharedPreferences; import android.os.ParcelFileDescriptor; import android.util.Log; import au.com.bytecode.opencsv.CSVReader; import au.com.bytecode.opencsv.CSVWriter; /** * Helper class to manage reading and writing the secrets file. The file * is encrypted using the ciphers created by the SecurityUtils helper * functions. * * Methods that touch the main secrets files are thread safe. This allows * the file to be saved in a background thread so that the UI is not blocked * in the most common use cases. Note that stopping the app and restarting * it again may still cause the UI to block if the write take a long time. * * @author rogerta */ @SuppressWarnings("javadoc") public class FileUtils { /** Return value for the getSaltAndRounds() function. */ public static class SaltAndRounds { public SaltAndRounds(byte[] salt, int rounds) { this.salt = salt; this.rounds = rounds; } public byte[] salt; public int rounds; } /** Name of the preferences file for backup. */ public static final String PREFS_FILE_NAME = "backup"; /** * Name of last backup date preference. Long value as number or millis as * returned by System.currentTimeMillis(). */ public static final String PREF_LAST_BACKUP_DATE = "last_backup_date"; /** * Name of last nag date preference. Long value as number or * millis as returned by System.currentTimeMillis(). This is the time at * which we last nagged the user to enable online backup. */ public static final String PREF_LAST_NAG_DATE = "last_nag_date"; /** Name of the secrets file. */ public static final String SECRETS_FILE_NAME = "secrets"; /** Name of the secrets backup file on the SD card. */ public static final String SECRETS_FILE_NAME_SDCARD = "/sdcard/secrets"; /** Name of the secrets CSV file on the SD card. */ public static final String SECRETS_FILE_NAME_CSV = "/sdcard/secrets.csv"; /** Name of the OI Safe CSV file on the SD card. */ public static final String OI_SAFE_FILE_NAME_CSV = "/sdcard/oisafe.csv"; private static final File SECRETS_FILE_CSV = new File(SECRETS_FILE_NAME_CSV); private static final File OI_SAFE_FILE_CSV = new File(OI_SAFE_FILE_NAME_CSV); /** Secrets CSV column names */ public static final String COL_DESCRIPTION = "Description"; public static final String COL_USERNAME = "Id"; public static final String COL_PASSWORD = "PIN"; public static final String COL_EMAIL = "Email"; public static final String COL_NOTES= "Notes"; private static final String EMPTY_STRING = ""; private static final String INDENT = " "; private static final String RP_PREFIX = "@"; // secrets ID for JSON private static final String JSON_SECRETS_ID = "secrets"; /** Tag for logging purposes. */ public static final String LOG_TAG = "FileUtils"; /** Lock for accessing main secrets file. */ private static final Object lock = new Object(); private static final byte[] SIGNATURE = {0x22, 0x34, 0x56, 0x79}; /** Does the secrets file exist? */ public static boolean secretsExist(Context context) { // Instead of just checking for the existence of the secrets file // explicitly, I will check for the existence of any file in the // application's data directory. This check is valid because: // // - when the user runs the app for the first time, an empty secrets file // is always written // - there is at least one file in existences even during the save // operation // // The benefit of making this assumption is that I don't need to acquire // the file lock in order to test for the existence of the secrets file. // This speeds up leaving the secrets list activity since the test for // existence does not need to wait for the save to finish. This also // speeds the wake time when secrets was active at the time the phone // went to sleep. String[] filenames = context.fileList(); // On some Samsung Galaxy S5/S6 devices, a file called // "rList-net.tawacentral.roger.secrets.LoginActivity" exists here. Not // sure why. However, it interferes with the detection of first run. // Trying to delete the file does not work, it comes back again. Therefore // ignore any files that are not an auto-backup or the main secrets file. int length = filenames.length; for (String filename: filenames) { if (SECRETS_FILE_NAME.equals(filename) || filename.startsWith(RP_PREFIX)) { continue; } --length; } return length > 0; } /** Does the secrets restore file exist on the SD card? */ public static boolean restoreFileExist() { File file = new File(SECRETS_FILE_NAME_SDCARD); return file.exists(); } /** * Gets the time of the last online backup. * * @param ctx A context to get the preferences from. * @return The time of the last online backup, as millisecs since epoch. */ public static long getTimeOfLastOnlineBackup(Context ctx) { return ctx.getSharedPreferences(PREFS_FILE_NAME, 0) .getLong(PREF_LAST_BACKUP_DATE, 0); } /** * Is the online backup too old? * * @param ctx A context to get the preferences from. * @return True if the last backup is too old. */ public static boolean isOnlineBackupTooOld(Context ctx) { long now = System.currentTimeMillis(); long lastSaved = getTimeOfLastOnlineBackup(ctx); // If lastSaved is zero, this means an online backup has never been done. // Nag the user about it, but no more than once per week. if (lastSaved == 0) { SharedPreferences prefs = ctx.getSharedPreferences(PREFS_FILE_NAME, 0); long lastNag = prefs.getLong(PREF_LAST_NAG_DATE, 0); final long sixDays = 6 * 24 * 60 * 60 * 1000; // 6 days in millis. final long oneWeek = 7 * 24 * 60 * 60 * 1000; // One week in millis. // Don't warn the very first day the user runs the program. if (lastNag == 0) { prefs.edit().putLong(PREF_LAST_NAG_DATE, now - sixDays).apply(); } else if ((now - lastNag) > oneWeek) { // In order not to nag users more than once a week about missing online // backup support, set the last nag time to now. prefs.edit().putLong(PREF_LAST_NAG_DATE, now).apply(); return true; } } return false; } /** Is the restore point too old? */ private static boolean isRestorePointTooOld(File file) { long lastModified = file.lastModified(); long now = System.currentTimeMillis(); long twoDays = 2 * 24 * 60 * 60 * 1000; // 2 days. return (now - lastModified) > twoDays; } /** * Get all existing restore points, including the restore file on the SD card * if it exists. * * @param context Activity context in which the save is called. * @return A list of all possible restore points. */ public static List<String> getRestorePoints(Context context) { String[] filenames = context.fileList(); ArrayList<String> list = new ArrayList<String>(filenames.length + 1); if (restoreFileExist()) list.add(SECRETS_FILE_NAME_SDCARD); for (String filename : filenames) { if (filename.startsWith(RP_PREFIX)) list.add(filename); } return list; } /** * Cleanup any residual data files from a previous bad run, if any. The * algorithm is as follows: * * - delete any file with "new" in the name. These are possibly partial * writes, so their contents is undefined. * - if no secrets file exists, rename the most recent auto restore point * file to secrets. * - if too many auto restore point files exist, delete the extra ones. * However, don't delete any auto-backups younger than 48 hours. * * @param context Activity context in which the save is called. */ public static void cleanupDataFiles(Context context) { Log.d(LOG_TAG, "FileUtils.cleanupDataFiles"); synchronized (lock) { String[] filenames = context.fileList(); int oldCount = filenames.length; boolean secretsFileExists = context.getFileStreamPath(SECRETS_FILE_NAME) .exists(); // Cleanup any partial saves and find the most recent auto-backup file. { File mostRecent = null; int mostRecentIndex = -1; for (int i = 0; i < filenames.length; ++i) { String filename = filenames[i]; if (0 == filename.indexOf("new")) { // This is a partial write file, probably corrupted. Delete it. context.deleteFile(filename); --oldCount; filenames[i] = null; } else if (filename.startsWith(RP_PREFIX)) { // This is an auto-backup file. Remember most recent. if (!secretsFileExists) { File f = context.getFileStreamPath(filename); if (null == mostRecent || f.lastModified() > mostRecent.lastModified()) { mostRecent = f; mostRecentIndex = i; } } } else { --oldCount; filenames[i] = null; } } // If we don't have a secrets file but found an auto-backup file, // rename the more recent auto-backup to secrets. if (null != mostRecent) { mostRecent.renameTo(context.getFileStreamPath(SECRETS_FILE_NAME)); --oldCount; filenames[mostRecentIndex] = null; } } // If there are too many old files, delete the oldest extra ones. while (oldCount > 10) { File oldest = null; int oldestIndex = -1; for (int i = 0; i < filenames.length; ++i) { String filename = filenames[i]; if (null == filename) continue; File f = context.getFileStreamPath(filename); if (null == oldest || f.lastModified() < oldest.lastModified()) { oldest = f; oldestIndex = i; } } if (null != oldest) { // If the oldest file is not too old, then just break out of the // loop. We don't want to delete any "old" files that are too // recent. if (!FileUtils.isRestorePointTooOld(oldest)) break; oldest.delete(); --oldCount; filenames[oldestIndex] = null; } } } } /** * Gets the salt and rounds already in use on this device, or null if none * exists. * * @param context Activity context in which the save is called. * @param path The file to read the salt and rounds from. Can either be the * string SECRETS_FILE_NAME_SDCARD, SECRETS_FILE_NAME, or the name of a * restore point. * @return the salt and rounds */ public static SaltAndRounds getSaltAndRounds(Context context, String path) { // The salt is stored as a byte array at the start of the secrets file. FileInputStream input = null; try { input = SECRETS_FILE_NAME_SDCARD.equals(path) ? new FileInputStream(path) : context.openFileInput(path); return getSaltAndRounds(input); } catch (Exception ex) { Log.e(LOG_TAG, "getSaltAndRounds", ex); } finally { try {if (null != input) input.close();} catch (IOException ex) {} } return new SaltAndRounds(null, 0); } /** * Gets the salt and rounds already in use on this device, or null if none * exists. * * @param input The stream to read the salt and rounds from. * @return the salt and rounds * * @throws IOException */ public static SaltAndRounds getSaltAndRounds(InputStream input) throws IOException { // The salt is stored as a byte array at the start of the secrets file. byte[] signature = new byte[SIGNATURE.length]; byte[] salt = null; int rounds = 0; input.read(signature); if (Arrays.equals(signature, SIGNATURE)) { int length = input.read(); salt = new byte[length]; input.read(salt); rounds = input.read(); if (rounds < 4 || rounds > 31) { salt = null; rounds = 0; } } return new SaltAndRounds(salt, rounds); } /** * Saves the secrets to file using the password retrieved from the user. * * @param context Activity context in which the save is called. * @param existing The file to save into. * @param cipher The encryption cipher to use with the file. * @param salt The salt used to create the cipher. * @param rounds The number of rounds for bcrypt. * @param secrets The collection of secrets to save. * @return True if saved successfully. */ public static int saveSecrets(Context context, File existing, Cipher cipher, byte[] salt, int rounds, ArrayList<Secret> secrets) { Log.d(LOG_TAG, "FileUtils.saveSecrets"); synchronized (lock) { Log.d(LOG_TAG, "FileUtils.saveSecrets: got lock"); // To be as safe as possible, for example to handle low space conditions, // we will save the secrets to a file using the following steps: // // 1- write the secrets to a new temporary file (tempn) // on error: delete tempn // 2- rename the existing secrets file, if any (to tempo) // on error: delete tempn // 3- rename the new temporary file to the official file name // on error: rename tempo back to existing, delete tempn // // Old files will hang around for a while. The cleanupDataFiles() // method, which is called whenever Secrets is re-launched, will make // sure that the old files don't accumulate indefinitely. String prefix = MessageFormat.format(RP_PREFIX + "{0,date,yy.MM.dd}-{0,time,HH:mm}", new Date(), null); File parent = existing.getParentFile(); File tempn = new File(parent, "new"); File tempo = new File(parent, prefix); for (int i = 0; tempn.exists() || tempo.exists(); ++i) { tempn = new File(parent, "new" + i); tempo = new File(parent, prefix + i); } // Step 1 FileOutputStream fos = null; try { fos = new FileOutputStream(tempn); writeSecrets(fos, cipher, salt, rounds, secrets); } catch (Exception ex) { Log.d(LOG_TAG, "FileUtils.saveSecrets: could not write secrets file"); // NOTE: this delete() works, even though the file is still open. tempn.delete(); return R.string.error_save_secrets; } finally { try {if (null != fos) fos.close();} catch (IOException ex) {} } // Step 2 if (existing.exists() && !existing.renameTo(tempo)) { Log.d(LOG_TAG, "FileUtils.saveSecrets: could not move existing file"); tempn.delete(); return R.string.error_cannot_move_existing; } // Step 3 if (!tempn.renameTo(existing)) { Log.d(LOG_TAG, "FileUtils.saveSecrets: could not move new file"); tempo.renameTo(existing); tempn.delete(); return R.string.error_cannot_move_new; } Log.d(LOG_TAG, "FileUtils.saveSecrets: done"); return 0; } } /** * Backup the secrets to SD card using the password retrieved from the user. * * @param context Activity context in which the backup is called. * @param cipher The encryption cipher to use with the file. * @param salt The salt used to create the cipher. * @param rounds The number of rounds for bcrypt. * @param secrets The list of secrets to save. * @return True if saved successfully */ public static boolean backupSecrets(Context context, Cipher cipher, byte[] salt, int rounds, ArrayList<Secret> secrets) { Log.d(LOG_TAG, "FileUtils.backupSecrets"); if (null == cipher) return false; FileOutputStream output = null; boolean success = false; try { output = new FileOutputStream(SECRETS_FILE_NAME_SDCARD); writeSecrets(output, cipher, salt, rounds, secrets); success = true; } catch (Exception ex) { } finally { try {if (null != output) output.close();} catch (IOException ex) {} } return success; } /* start new load/restore methods */ /* * Load methods. * These handle historic file and cipher formats for backward compatability * with old secrets and backup files. They differ in both the cipher used and * underlying data format. * * V1 used a cipher with fixed salt and rounds values (C1), and Java serialized * object format (F1). * V2 used a cipher with variable salt and round values (C2), stored in the secrets * file header, and Java serialized object format. Because of the new * stored values, the file format (F2) differs from V1. * V3 used a modified version of the V2 cipher (password fix) (C3), and the same * file format as V2. * Current (V4): uses same V3 cipher mechanism, and JSON file format (F3) * * Pictorially: * Cipher format * * | C1 | C2 | C3 * ---|------|------|------ * F1 | V1 | | * File ---|------|------|------ * format F2 | | V2 | V3 * ---|------|------|------ * F3 | | | V4 */ /** * Opens the secrets file using the password retrieved from the user. * * @param context Activity context in which the load is called. * @return A list of loaded secrets. */ public static ArrayList<Secret> loadSecrets(Context context) { synchronized (lock) { Log.d(LOG_TAG, "FileUtils.loadSecrets: got lock"); return loadSecrets(context, SECRETS_FILE_NAME, SecurityUtils.getCipherInfo()); } } /** * Opens the secrets file using the password retrieved from the user. * * @param context Activity context in which the load is called. * @param fileName Name of file to be loaded * @param info CipherInfo * @return A list of loaded secrets. */ public static ArrayList<Secret> loadSecrets(Context context, String fileName, CipherInfo info) { Log.d(LOG_TAG, "FileUtils.loadSecrets"); if (null == info) return null; ArrayList<Secret> secrets = null; InputStream input = null; try { input = SECRETS_FILE_NAME_SDCARD.equals(fileName) ? new FileInputStream(fileName) : context.openFileInput(fileName); secrets = readSecrets(input, info.decryptCipher, info.salt, info.rounds); } catch (Exception ex) { Log.e(LOG_TAG, "loadSecrets", ex); } finally { try { if (null != input) input.close(); } catch (IOException ex) { } } Log.d(LOG_TAG, "FileUtils.loadSecrets: done"); return secrets; } /** * Opens the secrets file using the password retrieved from the user and * the old encryption cipher. This function is called only for backward * compatibility purposes, when Secrets encounters an error trying to load * the secrets using the current encryption method. * * V1 used fixed rounds and salt, not stored in the file. * * @param context Activity context in which the load is called. * @param cipher Decryption cipher for old encryption. * @return A list of loaded secrets. */ public static ArrayList<Secret> loadSecretsV1(Context context, Cipher cipher) { synchronized (lock) { Log.d(LOG_TAG, "FileUtils.loadSecretsV1: got lock"); return loadSecretsV1(context, cipher, SECRETS_FILE_NAME); } } /** * See previous method for description. * * @param context Activity context in which the load is called. * @param cipher Decryption cipher for old encryption. * @param fileName Name of file to be loaded * @return A list of loaded secrets. */ @SuppressWarnings("unchecked") public static ArrayList<Secret> loadSecretsV1(Context context, Cipher cipher, String fileName) { Log.d(LOG_TAG, "FileUtils.loadSecretsV1"); if (null == cipher) return null; ArrayList<Secret> secrets = null; ObjectInputStream input = null; try { InputStream fis = SECRETS_FILE_NAME_SDCARD.equals(fileName) ? new FileInputStream(fileName) : context.openFileInput(fileName); input = new ObjectInputStream(new CipherInputStream(fis, cipher)); secrets = (ArrayList<Secret>)input.readObject(); } catch (Exception ex) { Log.e(LOG_TAG, "loadSecretsV1", ex); } finally { try {if (null != input) input.close();} catch (IOException ex) {} } Log.d(LOG_TAG, "FileUtils.loadSecretsV1: done"); return secrets; } /** * Opens the secrets file using the password retrieved from the user and * the old encryption cipher. This function is called only for backward * compatibility purposes, when Secrets encounters an error trying to load * the secrets using the current encryption method. * * V2 used variable rounds and salt, stored in the file header. * * @param context Activity context in which the load is called. * @param cipher Decryption cipher for old encryption. * @param salt The salt to use when creating the encryption key. * @param rounds The number of rounds for bcrypt. * @return A list of loaded secrets. */ public static ArrayList<Secret> loadSecretsV2(Context context, Cipher cipher, byte[] salt, int rounds) { synchronized (lock) { return loadSecretsV2(context, SECRETS_FILE_NAME, cipher, salt, rounds); } } /** * See previous method for description. * * @param context Activity context in which the load is called. * @param fileName Name of file to be loaded * @param cipher Decryption cipher for old encryption. * @param salt The salt to use when creating the encryption key. * @param rounds The number of rounds for bcrypt. * @return A list of loaded secrets. */ public static ArrayList<Secret> loadSecretsV2(Context context, String fileName, Cipher cipher, byte[] salt, int rounds) { Log.d(LOG_TAG, "FileUtils.loadSecretsV2"); if (null == cipher) return null; ArrayList<Secret> secrets = null; InputStream input = null; try { input = SECRETS_FILE_NAME_SDCARD.equals(fileName) ? new FileInputStream(fileName) : context.openFileInput(fileName); secrets = readSecretsV2(input, cipher, salt, rounds); } catch (Exception ex) { Log.e(LOG_TAG, "loadSecretsV2", ex); } finally { try { if (null != input) input.close(); } catch (IOException ex) { } } return secrets; } /** * Opens the secrets file using the password retrieved from the user. This * function is called only for backward compatibility purposes, when Secrets * encounters an error trying to load the secrets using the current encryption * method. * * V3 used the current cipher mechanism and the old object file format. * * @param context * Activity context in which the load is called. * @return A list of loaded secrets. */ public static ArrayList<Secret> loadSecretsV3(Context context) { synchronized (lock) { Log.d(LOG_TAG, "FileUtils.loadSecretsV21: got lock"); return loadSecretsV3(context, SecurityUtils.getCipherInfo(), SECRETS_FILE_NAME); } } /** * See previous method for description. * * @param context * Activity context in which the load is called. * @param fileName Name of file to be loaded * @param info CipherInfo * @return A list of loaded secrets. */ public static ArrayList<Secret> loadSecretsV3(Context context, CipherInfo info, String fileName) { Log.d(LOG_TAG, "FileUtils.loadSecretsV3"); if (null == info) return null; ArrayList<Secret> secrets = null; InputStream input = null; try { input = SECRETS_FILE_NAME_SDCARD.equals(fileName) ? new FileInputStream(fileName) : context.openFileInput(fileName); secrets = readSecretsV2(input, info.decryptCipher, info.salt, info.rounds); } catch (Exception ex) { Log.e(LOG_TAG, "loadSecretsV3", ex); } finally { try { if (null != input) input.close(); } catch (IOException ex) { } } Log.d(LOG_TAG, "FileUtils.loadSecretsv3: done"); return secrets; } /* end new load/restore methods */ /** * Writes the secrets to the given output stream encrypted with the given * cipher. * * The output stream is closed by the caller. * * @param output The output stream to write the secrets to. * @param cipher The cipher to encrypt the secrets with. * @param secrets The secrets to write. * @param rounds The number of rounds for bcrypt. * @throws IOException */ private static void writeSecrets(OutputStream output, Cipher cipher, byte[] salt, int rounds, ArrayList<Secret> secrets) throws IOException { output.write(SIGNATURE); output.write(salt.length); output.write(salt); output.write(rounds); output.write(FileUtils.toEncryptedJSONSecretsStream(cipher, secrets)); output.flush(); } /** * Read the secrets from the given input stream, decrypting with the given * cipher. * * @param input * The input stream to read the secrets from. * @param cipher * The cipher to decrypt the secrets with. * @return The secrets read from the stream. * @throws IOException * @throws ClassNotFoundException */ private static ArrayList<Secret> readSecrets(InputStream input, Cipher cipher, byte[] salt, int rounds) throws IOException, ClassNotFoundException { SaltAndRounds pair = getSaltAndRounds(input); if (!Arrays.equals(pair.salt, salt) || pair.rounds != rounds) { return null; } BufferedInputStream bis = new BufferedInputStream(input); ByteArrayOutputStream buffer = new ByteArrayOutputStream(); try { // read the whole stream into the buffer int nRead; byte[] data = new byte[4096]; while ((nRead = bis.read(data, 0, data.length)) != -1) { buffer.write(data, 0, nRead); } buffer.flush(); return FileUtils.fromEncryptedJSONSecretsStream(cipher, buffer.toByteArray()); } finally { try { if (null != bis) bis.close(); } catch (IOException ex) { } } } /** * Read the secrets from the given input stream, decrypting with the given * cipher. This uses the old object format and exists for compatibility. * * @param input The input stream to read the secrets from. * @param cipher The cipher to decrypt the secrets with. * @return The secrets read from the stream. * @throws IOException * @throws ClassNotFoundException */ @SuppressWarnings("unchecked") private static ArrayList<Secret> readSecretsV2(InputStream input, Cipher cipher, byte[] salt, int rounds) throws IOException, ClassNotFoundException { SaltAndRounds pair = getSaltAndRounds(input); if (!Arrays.equals(pair.salt, salt) || pair.rounds != rounds) { return null; } ObjectInputStream oin = new ObjectInputStream( new CipherInputStream(input, cipher)); try { return (ArrayList<Secret>)oin.readObject(); } finally { try {if (null != oin) oin.close();} catch (IOException ex) {} } } /** * Returns an json object representing the contained secrets. * * @param secrets * The list of secrets. * @return String of secrets * @throws JSONException */ public static JSONObject toJSONSecrets(ArrayList<Secret> secrets) throws JSONException { JSONObject jsonValues = new JSONObject(); JSONArray jsonSecrets = new JSONArray(); for (Secret secret : secrets) { jsonSecrets.put(secret.toJSON()); } jsonValues.put(JSON_SECRETS_ID, jsonSecrets); return jsonValues; } /** * Constructs a secrets collection from the supplied JSON object * * @param jsonValues * JSON object * @return list of secrets * @throws JSONException * if error with JSON data */ public static ArrayList<Secret> fromJSONSecrets(JSONObject jsonValues) throws JSONException { JSONArray jsonSecrets = jsonValues.getJSONArray(JSON_SECRETS_ID); ArrayList<Secret> secretList = new ArrayList<Secret>(); for (int i = 0; i < jsonSecrets.length(); i++) { secretList.add(Secret.fromJSON((JSONObject) jsonSecrets.get(i))); } return secretList; } /** * Returns an encrypted json stream representing the user's secrets. * * @param cipher * The encryption cipher to use with the file. * @param secrets * The list of secrets. * @return byte array of secrets * @throws IOException * if any error occurs */ public static byte[] toEncryptedJSONSecretsStream(Cipher cipher, ArrayList<Secret> secrets) throws IOException { CipherOutputStream output = null; ByteArrayOutputStream baos = new ByteArrayOutputStream(); try { output = new CipherOutputStream(baos, cipher); output.write(FileUtils.toJSONSecrets(secrets).toString().getBytes("UTF-8")); } catch (Exception e) { Log.e(LOG_TAG, "toEncryptedJSONSecretsStream", e); throw new IOException("toEncryptedJSONSecretsStream failed: " + e.getMessage()); } finally { try { if (null != output) output.close(); } catch (IOException ex) {} } return baos.toByteArray(); } /** * Constructs secrets from the supplied encrypted byte stream * * @param cipher * cipher to use * @param secrets * encrypted byte array * @return list of secrets * @throws IOException * if any error occurs */ public static ArrayList<Secret> fromEncryptedJSONSecretsStream(Cipher cipher, byte[] secrets) throws IOException { try { byte[] secretStrBytes = cipher.doFinal(secrets); JSONObject jsonValues = new JSONObject(new String(secretStrBytes, "UTF-8")); return FileUtils.fromJSONSecrets(jsonValues); } catch (Exception e) { Log.e(LOG_TAG, "fromEncryptedJSONSecretsStream", e); throw new IOException("fromEncryptedJSONSecretsStream failed: " + e.getMessage()); } } /** Deletes all secrets from the phone. * @param context the current context * @return always true */ public static boolean deleteSecrets(Context context) { Log.d(LOG_TAG, "FileUtils.deleteSecrets"); synchronized (lock) { String filenames[] = context.fileList(); for (String filename : filenames) { context.deleteFile(filename); } } return true; } /** * Export secrets to a CSV file on the SD card. See the description of * the importSecrets() method for more details about the format written. * @param context the current context * @param secrets the secrets to export * @return true if successful, false otherwise */ public static boolean exportSecrets(Context context,List<Secret> secrets) { // An array to hold the rows that will be written to the CSV file. String[] row = new String[] { COL_DESCRIPTION, COL_USERNAME, COL_PASSWORD, COL_EMAIL, COL_NOTES }; CSVWriter writer = null; boolean success = false; try { writer = new CSVWriter(new FileWriter(SECRETS_FILE_NAME_CSV)); // Write descriptive headers. writer.writeNext(row); // Write out each secret. for (Secret secret : secrets) { row[0] = secret.getDescription(); row[1] = secret.getUsername(); row[2] = secret.getPassword(true); // true: forExport row[3] = secret.getEmail(); row[4] = secret.getNote(); // NOTE: writeNext() handles nulls in row[] gracefully. writer.writeNext(row); success = true; } } catch (Exception ex) { Log.e(LOG_TAG, "exportSecrets", ex); } finally { try {if (null != writer) writer.close();} catch (IOException ex) {} } return success; } /** * Returns the file that should be imported. This method will look for a file * on the SD card whose name is either the secrets CSV file or the OI Safe * CSV file. To support other formats, should add some code here to detect * those files. * * If more than one CSV file of exist, the one last modified is used. * @return the file to import */ public static File getFileToImport() { boolean haveSecretsCsv = SECRETS_FILE_CSV.exists(); boolean haveOiSafeCsv = OI_SAFE_FILE_CSV.exists(); File file = null; if (haveSecretsCsv && haveOiSafeCsv) { if (SECRETS_FILE_CSV.lastModified() > OI_SAFE_FILE_CSV.lastModified()) { file = SECRETS_FILE_CSV; } else { file = OI_SAFE_FILE_CSV; } } else if (haveSecretsCsv) { file = SECRETS_FILE_CSV; } else if (haveOiSafeCsv) { file = OI_SAFE_FILE_CSV; } return file; } /** * Import secrets from a CSV file. This function will first clear the secrets * list argument before adding anything read from the file. If there are no * errors while reading, true is returned. * * Note that its possible for this function to return false, and yet the * secrets list is not empty. This means that some, but not all, secrets * were able to be read from the file. * * For the moment, this function assumes that the first line is a description * so it is not imported. At least 5 columns are assumed for each line: * description, username, password, email, and notes, in that order (this is * the default format as written by exportSecrets()). * * This function will attempt to detect OI Safe CSV files and import them * accordingly. It does this by reading the first line of file, and looking * for column descriptions as exported by OI Safe 1.1.0. * * @param context Activity context for global services * @param file File to import * @param secrets List to append secrets read from the file * @return True if all secrets read successfully, and false otherwise */ public static boolean importSecrets(Context context, File file, ArrayList<Secret> secrets) { secrets.clear(); boolean isOiSafeCsv = false; boolean isSecretsScv = false; boolean success = false; CSVReader reader = null; try { reader = new CSVReader(new FileReader(file)); // Use the first line to determine the type of csv file. Secrets will // output 5 columns, with the names as used in the exportSecrets() // function. OI Safe 1.1.0 is also detected. String headers[] = reader.readNext(); if (null != headers) { isSecretsScv = isSecretsCsv(headers); if (!isSecretsScv) isOiSafeCsv = isOiSafeCsv(headers); } // Read all the rest of the lines as secrets. for (;;) { String[] row = reader.readNext(); if (null == row) break; Secret secret = new Secret(); if (isOiSafeCsv) { secret.setDescription(row[1]); secret.setUsername(row[3]); secret.setPassword(row[4], false); secret.setEmail(EMPTY_STRING); // I will combine the category, website, and notes columns into // the notes field in secrets. int approxMaxLength = row[0].length() + row[2].length() + row[5].length() + 32; StringBuilder builder = new StringBuilder(approxMaxLength); builder.append(row[5]).append("\n\n"); builder.append("Category: ").append(row[0]).append('\n'); if (null != row[2] && row[2].length() > 0) builder.append("Website: ").append(row[2]).append('\n'); secret.setNote(builder.toString()); } else { // If we get here, then this may be an unknown format. For better // or for worse, this is a "best effort" to import that data. secret.setDescription(row[0]); secret.setUsername(row[1]); secret.setPassword(row[2], false); secret.setEmail(row[3]); secret.setNote(row[4]); } secrets.add(secret); } // We'll only return complete success if we get here, and we detected // that we knew the file format. This will give the user an indication // do look at the secrets if the format was not automatically detected. success = isOiSafeCsv || isSecretsScv; } catch (Exception ex) { Log.e(LOG_TAG, "importSecrets", ex); } finally { try {if (null != reader) reader.close();} catch (IOException ex) {} } return success; } /** Is it likely that the CSV file is in OI Safe format? */ private static boolean isOiSafeCsv(String[] headers) { if (headers[0].equalsIgnoreCase("Category") && headers[1].equalsIgnoreCase("Description") && headers[2].equalsIgnoreCase("Website") && headers[3].equalsIgnoreCase("Username") && headers[4].equalsIgnoreCase("Password") && headers[5].equalsIgnoreCase("Notes")) return true; return false; } /** Is it likely that the CSV file is in secrets format? */ private static boolean isSecretsCsv(String[] headers) { if (headers[0].equalsIgnoreCase(COL_DESCRIPTION) && headers[1].equalsIgnoreCase(COL_USERNAME) && headers[2].equalsIgnoreCase(COL_PASSWORD) && headers[3].equalsIgnoreCase(COL_EMAIL) && headers[4].equalsIgnoreCase(COL_NOTES)) return true; return false; } /** Returns a list of the supported CSV file names, newline separated. */ public static String getCsvFileNames() { StringBuilder builder = new StringBuilder(); builder.append(INDENT).append(SECRETS_FILE_CSV.getName()).append('\n'); builder.append(INDENT).append(OI_SAFE_FILE_CSV.getName()); return builder.toString(); } static public class SecretsBackupAgent extends BackupAgentHelper { /** Tag for logging purposes. */ public static final String LOG_TAG_AGENT = "SecretsBackupAgent"; /** Key in backup set for file data. */ private static final String KEY ="file"; @Override public void onCreate() { Log.d(LOG_TAG_AGENT, "onCreate"); FileBackupHelper helper = new FileBackupHelper(this, FileUtils.SECRETS_FILE_NAME); addHelper(KEY, helper); } @Override public void onBackup(ParcelFileDescriptor oldState, BackupDataOutput data, ParcelFileDescriptor newState) throws IOException { Log.d(LOG_TAG_AGENT, "onBackup"); synchronized (lock) { super.onBackup(oldState, data, newState); } getSharedPreferences(PREFS_FILE_NAME, 0).edit() .putLong(PREF_LAST_BACKUP_DATE, System.currentTimeMillis()).apply(); } @Override public void onRestore(BackupDataInput data, int appVersionCode, ParcelFileDescriptor newState) throws IOException { Log.d(LOG_TAG_AGENT, "onRestore"); synchronized (lock) { super.onRestore(data, appVersionCode, newState); } } @Override public void onDestroy() { Log.d(LOG_TAG_AGENT, "onDestroy"); super.onDestroy(); } } }