// 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.IOException; import java.io.ObjectInputStream; import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; import java.util.List; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import android.util.Log; /** * Represents one secret. The assumption is that each secret is describable, * uses a combination of username/password, and may require a separate email * address for management purposes. Finally, an arbitrary note can be attached. * * @author rogerta */ @SuppressWarnings("javadoc") public class Secret implements Comparable<Secret>, Serializable { private static final long serialVersionUID = -116450416616138469L; private static final int THRESHOLD_MS = 60 * 1000; private static final int MAX_LOG_SIZE = 100; // Tag for logging purposes public static final String LOG_TAG = "Secret"; // Secret field names private static final String SECRET_DESCRIPTION = "description"; private static final String SECRET_USERNAME = "username"; private static final String SECRET_PASSWORD = "password"; private static final String SECRET_EMAIL = "email"; private static final String SECRET_NOTE = "note"; private static final String SECRET_ACCESS_LOG = "log"; private static final String SECRET_TIMESTAMP = "timestamp"; private static final String SECRET_DELETED = "deleted"; // Secret fields private String description; private String username; private String password; private String email; private String note; private ArrayList<LogEntry> access_log; /* soft deletion indicator */ private boolean deleted; /** * An immutable class that represents one entry in the access log. Each * time the password is viewed or modified, the access log is updated with * a new log entry. * * Log entries are pruned so that they don't grow indefinitely. The * maximum size of an access log is given by the MAX_LOG_SIZE constant. * The CREATED log entry is never pruned though. * * @author rogerta */ public static final class LogEntry implements Serializable { private static final long serialVersionUID = -9024951856209882415L; // Log entry types. public static final int CREATED = 1; public static final int VIEWED = 2; public static final int CHANGED = 3; public static final int EXPORTED = 4; public static final int SYNCED = 5; public static final int DELETED = 6; // Log field names private static final String LOG_TYPE = "type"; private static final String LOG_TIME = "time"; private int type_; private long time_; /** Used internally to create the CREATED log entry. */ LogEntry() { type_ = CREATED; time_ = System.currentTimeMillis(); } /** * Creates a new log entry object of the given type for the given time. * * @param type One of the supported types. * @param time Time of the entry, in milliseconds. */ public LogEntry(int type, long time) { this.type_ = type; this.time_ = time; } /** * Returns the type of this log entry. * * @return One of the supported types. */ public int getType() { return type_; } /** Returns the time stamp associated with this log entry. */ public long getTime() { return time_; } private JSONObject toJSON() throws JSONException { JSONObject jsonValues = new JSONObject(); jsonValues.put(LOG_TYPE, getType()); jsonValues.put(LOG_TIME, getTime()); return jsonValues; } /** * Generate LogEntry from json object * @param json object * @return LogEntry * @throws JSONException */ public static LogEntry fromJSON(JSONObject jsonValues) throws JSONException { return new LogEntry(jsonValues.getInt(LOG_TYPE), jsonValues.getLong(LOG_TIME)); } } /** * Creates a new secret where all fields are empty. The access log contains * only one CREATED entry, with the current time. */ public Secret() { access_log = new ArrayList<LogEntry>(); access_log.add(new LogEntry()); } /** * This method exists only to recover from a corrupted save file. As each * secret is successfully read, it is added to a global array. If the save * file cannot read the entire array because the end is corrupted, the global * array will contain those that were read successfully. * * Its the responsibility of the load code to clear the global array before * and after reading from the file. This code assumes that only one thread * tries to load Secrets from an input stream at a time. * * @param stream * @throws IOException * @throws ClassNotFoundException */ private void readObject(ObjectInputStream stream) throws IOException, ClassNotFoundException { stream.defaultReadObject(); } public void setDescription(String description) { this.description = description; } public String getDescription() { return description; } public void setUsername(String username) { this.username = username; } public String getUsername() { return username; } /** * Sets the password for the secret, updating the access log with a * CHANGED entry if requested. * * @param password The new password * @param createDefaultLogEntry If true, create a CHANGED entry, otherwise * do nothing */ public void setPassword(String password, boolean createDefaultLogEntry) { if (createDefaultLogEntry) { createLogEntry(LogEntry.CHANGED); } this.password = password; } /** * Create a new log entry for the specified type, under the following * conditions. * If the specified type is: * VIEWED - if the previous entry is recent, do nothing * CHANGED - if the previous entry is VIEWED and is recent, remove it * before creating the new entry * EXPORTED - always create a new entry * SYNCED (input) - always create a new entry * DELETED - always create a new entry * * Any other type is ignored. * * The first two conditons are to prevent too many log entries. * * @param type VIEWED, CHANGED, EXPORTED, SYNCED */ private void createLogEntry(int type) { if (!(type == LogEntry.VIEWED || type == LogEntry.CHANGED || type == LogEntry.EXPORTED || type == LogEntry.SYNCED || type == LogEntry.DELETED)) { return; } long now = System.currentTimeMillis(); if (type == LogEntry.VIEWED || type == LogEntry.CHANGED) { LogEntry lastEntry = access_log.get(0); if (now - lastEntry.getTime() < THRESHOLD_MS) { if (type == LogEntry.VIEWED) return; if (lastEntry.getType() == LogEntry.VIEWED) { access_log.remove(0); } } } access_log.add(0, new LogEntry(type, now)); pruneAccessLog(); } /** * Gets the password for the secret, updating the access log with a * VIEWED (if the most recent access is not too recent) or EXPORTED entry. * @param forExport if true create EXPORTED log entry, else VIEWED * @return password */ public String getPassword(boolean forExport) { createLogEntry(forExport ? LogEntry.EXPORTED : LogEntry.VIEWED); return password; } public void setEmail(String email) { this.email = email; } public String getEmail() { return email; } public void setNote(String note) { this.note = note; } public String getNote() { return note; } /** * @return the deleted */ public boolean isDeleted() { return deleted; } /** * Set the secret as deleted */ public void setDeleted() { deleted = true; createLogEntry(LogEntry.DELETED); } /** * Update this secret from another * @param from source secret * @param reason Log entry value */ public void update(Secret from, int reason) { if (!(reason == LogEntry.CHANGED || reason == LogEntry.SYNCED || equals(from))) return; setPassword(from.password, false); username = from.getUsername(); email = from.getEmail(); note = from.getNote(); createLogEntry(reason); } /** * Convert secret to a JSON OBJECT * @return JSON representation of a secret * @throws JSONException */ public JSONObject toJSON() throws JSONException { JSONObject jsonSecret = new JSONObject(); jsonSecret.put(SECRET_DESCRIPTION, description); jsonSecret.put(SECRET_USERNAME, username); jsonSecret.put(SECRET_PASSWORD, password); jsonSecret.put(SECRET_EMAIL, email); jsonSecret.put(SECRET_NOTE, note); jsonSecret.put(SECRET_TIMESTAMP, getLastChangedTime()); jsonSecret.put(SECRET_DELETED, deleted); JSONArray jsonLog = new JSONArray(); for (LogEntry logEntry : access_log) { jsonLog.put(logEntry.toJSON()); } jsonSecret.put(SECRET_ACCESS_LOG, jsonLog); return jsonSecret; } /** * Convert JSON object to a Secret * @param jsonSecret JSON object * @return instance of a Secret * @throws JSONException */ public static Secret fromJSON(JSONObject jsonSecret) throws JSONException { Secret secret = new Secret(); secret.description = jsonSecret.getString(SECRET_DESCRIPTION); secret.username = jsonSecret.getString(SECRET_USERNAME); secret.password = jsonSecret.getString(SECRET_PASSWORD); secret.email = jsonSecret.getString(SECRET_EMAIL); secret.note = jsonSecret.getString(SECRET_NOTE); if (jsonSecret.has(SECRET_DELETED)) secret.deleted = jsonSecret.getBoolean(SECRET_DELETED); if (jsonSecret.has(SECRET_ACCESS_LOG)) { JSONArray jsonLog = jsonSecret.getJSONArray(SECRET_ACCESS_LOG); ArrayList<LogEntry> log = new ArrayList<LogEntry>(jsonLog.length()); for (int i = 0; i < jsonLog.length(); i++) { log.add(LogEntry.fromJSON((JSONObject)jsonLog.get(i))); } secret.access_log = log; if (!(log.size() > 0)) { Log.w(LOG_TAG, "Empty access log for secret '" + secret.description + "'"); } } // at this point we still may have no log, or an empty log. // we must have a log with at least a CREATED entry if (secret.access_log == null) { secret.access_log = new ArrayList<LogEntry>(); } if (secret.access_log.size() == 0) { secret.access_log.add(0, new LogEntry()); } return secret; } public String toString() { StringBuilder sb = new StringBuilder(); sb.append("d=").append(description); sb.append(",u=").append(username); sb.append(",p=").append(password); sb.append(",e=").append(email); return sb.toString(); } /* (non-Javadoc) * @see java.lang.Object#equals(java.lang.Object) */ @Override public boolean equals(Object o) { if (o instanceof Secret) return ((Secret)o).description.equalsIgnoreCase(description); return false; } /* (non-Javadoc) * @see java.lang.Comparable#compareTo(java.lang.Object) */ @Override public int compareTo(Secret anotherSecret) { return description.compareToIgnoreCase(anotherSecret.description); } /** * Get an unmodifiable list of access logs, in reverse chronological order, * for this secret. */ public List<LogEntry> getAccessLog() { return Collections.unmodifiableList(access_log); } /** * A helper function to return the most recent access log entry of this * secret. */ public LogEntry getMostRecentAccess() { return access_log.get(0); } /** * Get last changed time * @return long time */ public long getLastChangedTime() { for (int i = 0; i < access_log.size(); i++) { LogEntry entry = access_log.get(i); if (entry.getType() == LogEntry.CHANGED || entry.getType() == LogEntry.SYNCED || entry.getType() == LogEntry.CREATED || entry.getType() == LogEntry.DELETED) { return entry.getTime(); } } return 0; } /** * Prune the size of the access log to the maximum size by getting rid of * the oldest entries. The "created" log entry is never pruned away. */ private void pruneAccessLog() { // TODO(rogerta): may want to give lower priority to VIEWED entries. Could // maybe implement this by doing a first pass that removes VIEWED entries // first to see if we can reach the limit. If not, then do a paas to delete // either VIEWED or CHANGED entries. // // Need to be careful about an entry that is modified often, in which case // a naive implementation of the above could end up never storing any and // VIEWED entries. while(access_log.size() > MAX_LOG_SIZE) { // The "created" entry is always the last one in the list, and there // is only ever one. So try to delete the second last item. int index = access_log.size() - 2; access_log.remove(index); } } }