/** * @author Dirk Bergstrom * * Keyring for webOS - Easy password management on your phone. * Copyright (C) 2009-2010, Dirk Bergstrom, keyring@otisbean.com * * This program 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 3 of the License, or * (at your option) any later version. * * This program 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 this program. If not, see <http://www.gnu.org/licenses/>. */ package com.otisbean.keyring; import java.io.BufferedReader; import java.io.ByteArrayOutputStream; import java.io.DataOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.OutputStreamWriter; import java.io.UnsupportedEncodingException; import java.io.Writer; import java.net.URL; import java.net.URLConnection; import java.net.URLEncoder; import java.security.GeneralSecurityException; import java.security.InvalidKeyException; import java.security.MessageDigest; import java.text.SimpleDateFormat; import java.util.Collection; import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.Random; import java.util.SortedMap; import java.util.TreeMap; import java.util.Vector; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import net.iharder.Base64; import org.json.simple.JSONObject; import org.json.simple.parser.JSONParser; import org.json.simple.parser.ParseException; import com.Ostermiller.util.CSVPrinter; /** * A Keyring of Items. * * The Mojo.Model.encrypt/decrypt API hides a lot of complexity under the * hood. I did a lot of investigating and pestered Palm repeatedly, and * finally got the details of what their API does. The following quotes * are from Palm engineers: * * """We use blowfish 64-bit block cipher. The input string is treated * as UTF-8 bytes which become the "key." The output from the * encrypter (which is binary) is base64 encoded, and returned to * JavaScript as a string. Decrypt takes the base64'ed string, * converts it to the binary stream, and runs the blowfish 64-bit * block decoder on it. It is assumed the same key "string" is used * in both cases.""" * * """"At it's core our Mojo Blowfish is using openssl to implement the * algorithm. We pass the full key string in (no padding or truncation). * We do pass in an initialization vector (8 zero bytes) and use CFB64 * Blowfish.""" * * This is documented on Palm's developer forums here: * * http://developer.palm.com/distribution/viewtopic.php?f=8&t=1281 * * @author Dirk Bergstrom */ public class Ring { /** * Version 4 introduced salting of data. * Version 3 was the first to have categories. */ public static final int SCHEMA_VERSION = 4; public static final int DB_SALT_LENGTH = 16; public static final int ITEM_SALT_LENGTH = 4; private String salt; private String checkData; private SecretKeySpec key; private IvParameterSpec iv; private int schemaVersion; private Cipher cipher; private Map<Integer, String> categoriesById = new HashMap<Integer, String>(); private SortedMap<String, Integer> categoriesByName = new TreeMap<String, Integer>(); private Map<String, Item> db = new HashMap<String, Item>(); private int nextCategory = 1; private Random rnd; JSONParser parser; private boolean fullyLoaded; private String cryptedDb; private JSONObject prefs; /** * Initialize the Ring with a String password. * * @param password * @throws GeneralSecurityException * @deprecated Need to use the more secure char[] method. */ public Ring(String password) throws GeneralSecurityException { this(password.toCharArray()); } public Ring(char[] password) throws GeneralSecurityException { this(); String rawCheckData = initCipher(password); checkData = encrypt(rawCheckData, 8); } public Ring() throws GeneralSecurityException { log("Ring()"); this.schemaVersion = SCHEMA_VERSION; this.rnd = new Random(); salt = saltString(12, null); cipher = Cipher.getInstance("Blowfish/CFB64/NoPadding"); // TODO we don't need the parser if we're just doing format conversion setDefaultCategories(); parser = new JSONParser(); } /** * Initialize the cipher object and create the key object. * * @param password * @return A checkData string, which can be compared against the existing * one to determine if the password is valid. * @throws GeneralSecurityException */ private String initCipher(char[] password) throws GeneralSecurityException { log("initCipher()"); String base64Key = null; try { // Convert a char array into a UTF-8 byte array ByteArrayOutputStream baos = new ByteArrayOutputStream(); OutputStreamWriter out = new OutputStreamWriter(baos, "UTF-8"); try { out.write(password); out.close(); } catch (IOException e) { // the only reason this would throw is an encoding problem. throw new RuntimeException(e.getLocalizedMessage()); } byte[] passwordBytes = baos.toByteArray(); /* The following code looks like a lot of monkey-motion, but it yields * results compatible with the on-phone Keyring Javascript and Mojo code. * * In newPassword() in ring.js, we have this (around line 165): * this._key = b64_sha256(this._salt + newPassword); */ byte[] saltBytes = salt.getBytes("UTF-8"); MessageDigest md = MessageDigest.getInstance("SHA-256"); md.update(saltBytes, 0, saltBytes.length); md.update(passwordBytes, 0, passwordBytes.length); byte[] keyHash = md.digest(); String paddedBase64Key = Base64.encodeBytes(keyHash); /* The Javascript SHA-256 library used in Keyring doesn't pad base64 output, * so we need to trim off any trailing "=" signs. */ base64Key = paddedBase64Key.replace("=", ""); byte[] keyBytes = base64Key.getBytes("UTF-8"); /* Keyring passes data to Mojo.Model.encrypt(key, data), which eventually * make a JNI call to OpenSSL's blowfish api. The following is the * equivalent in straight up JCE. */ key = new SecretKeySpec(keyBytes, "Blowfish"); iv = new IvParameterSpec( new byte[] { 0, 0, 0, 0, 0, 0, 0, 0 } ); } catch (UnsupportedEncodingException e) { // This is a bit dodgy, but handling a UEE elsewhere is foolish throw new GeneralSecurityException(e.getLocalizedMessage()); } return "{" + base64Key + "}"; } public boolean validatePassword(char[] password) throws GeneralSecurityException { log("validatePassword()"); String tmpCheckData = initCipher(password); if (! fullyLoaded) { /* Startup in process. See if the supplied password will * decrypt the db. */ return decryptLoadedData(); } else { return decrypt(checkData).equals(tmpCheckData); } } /** * The format for Keyring export is: * { * schema_version: this.SCHEMA_VERSION, * salt: this._salt, * db: encrypt(JSON.stringify(this._dataObject())) * } * * Where _dataObject() returns * { * db: this.db, * categories: this.categories, * crypt: { * salt: this._salt, * checkData: this._checkData * }, * prefs: this.prefs * } * * @return a JSON string of the export-formatted data. * @throws GeneralSecurityException */ @SuppressWarnings("unchecked") public JSONObject getExportData() throws GeneralSecurityException { log("getExportData()"); JSONObject dataObject = new JSONObject(); dataObject.put("db", db); dataObject.put("categories", categoriesById); JSONObject crypt = new JSONObject(); crypt.put("salt", salt); crypt.put("checkData", checkData); dataObject.put("crypt", crypt); if (null != prefs) { dataObject.put("prefs", prefs); } JSONObject export = new JSONObject(); export.put("schema_version", schemaVersion); export.put("salt", salt); export.put("db", encrypt(dataObject.toJSONString(), DB_SALT_LENGTH)); return export; } /** * Encrypt the given data with our key, prepending saltLength random * characters. * @return Base64 encoded representation of the encrypted data. */ String encrypt(String data, int saltLength) throws GeneralSecurityException { log("encrypt()"); try { cipher.init(Cipher.ENCRYPT_MODE, key, iv); } catch (InvalidKeyException ike) { throw new GeneralSecurityException("InvalidKeyException: " + ike.getLocalizedMessage() + "\nYou (probably) need to " + "install the \"Java Cryptography Extension (JCE) " + "Unlimited Strength Jurisdiction Policy\" files. Go to " + "http://java.sun.com/javase/downloads/index.jsp, download them, " + "and follow the instructions."); } String salted = saltString(saltLength, data); byte[] crypted; byte[] saltedBytes; try { saltedBytes = salted.getBytes("UTF-8"); } catch (UnsupportedEncodingException e) { throw new GeneralSecurityException(e.getLocalizedMessage()); } crypted = cipher.doFinal(saltedBytes); return Base64.encodeBytes(crypted); } String decrypt(String cryptext) throws GeneralSecurityException { log("decrypt()"); try { cipher.init(Cipher.DECRYPT_MODE, key, iv); } catch (InvalidKeyException ike) { throw new GeneralSecurityException("InvalidKeyException: " + ike.getLocalizedMessage() + "\nYou (probably) need to " + "install the \"Java Cryptography Extension (JCE) " + "Unlimited Strength Jurisdiction Policy\" files. Go to " + "http://java.sun.com/javase/downloads/index.jsp, download them, " + "and follow the instructions."); } byte[] crypted; try { crypted = Base64.decode(cryptext); } catch (IOException e) { throw new GeneralSecurityException(e.getLocalizedMessage()); } byte[] decrypted = cipher.doFinal(crypted); String salted; try { salted = new String(decrypted, "UTF-8"); } catch (UnsupportedEncodingException e) { throw new GeneralSecurityException(e.getLocalizedMessage()); } // Remove any leading non-JSON salt characters return salted.replaceAll("^[^\\{]*\\{", "{"); } /** * Generate random salt characters, optionally prepending them to the * supplied suffix. * * @param numChars Generate this many random characters. * @param suffix If non-null, append to the salt. */ private String saltString(int numChars, String suffix) { StringBuilder salted = new StringBuilder(); for (int i = 0; i < numChars; i++) { // Random character from ASCII 33 to 122 char c = (char) (rnd.nextInt(89) + 33); salted.append(c); } if (null != suffix) { salted.append(suffix); } return salted.toString(); } public boolean removeItem(Item item) { return null != db.remove(item.getTitle()); } public void addItem(Item item) { db.put(item.getTitle(), item); fullyLoaded = true; } public Item getItem(String title) { return db.get(title); } public Collection<Item> getItems() { return db.values(); } public String getSalt() { return salt; } /** * @return Ordered list of category names, without the "All" pseudo-category, * and with "Unfiled" at the top. */ public Vector<String> getCategories() { Vector<String> cats = new Vector<String>(categoriesByName.keySet()); cats.remove("All"); cats.remove("Unfiled"); cats.add(0, "Unfiled"); return cats; } public synchronized int categoryIdForName(String categoryName) { if ("Unfiled".equalsIgnoreCase(categoryName)) { return 0; } Integer retval = categoriesByName.get(categoryName); if (null == retval) { retval = nextCategory; nextCategory++; categoriesByName.put(categoryName, retval); categoriesById.put(retval, categoryName); } return retval; } public String categoryNameForId(int categoryid) { if (0 == categoryid) { return "Unfiled"; } String retval = categoriesById.get(categoryid); if (null == retval) { throw new RuntimeException("No category found for id " + categoryid); } return retval; } /** * Put the default "Unfiled" and "All" categories into the Maps. */ private void setDefaultCategories() { categoriesById.put(0, "Unfiled"); categoriesById.put(-1, "All"); categoriesByName.put("Unfiled", 0); categoriesByName.put("All", -1); } public void load(String inFile) throws IOException, KeyringException { log("load(" + inFile + ")"); InputStream is; if (inFile.equals("-")) { is = System.in; } else if (inFile.startsWith("http")) { is = new URL(inFile).openStream(); } else { is = new FileInputStream(new File(inFile)); } BufferedReader reader = new BufferedReader(new InputStreamReader(is, "UTF-8")); JSONObject obj; try { obj = (JSONObject) parser.parse(reader); } catch (ParseException e) { // ParseException's toString() method returns a good error message throw new KeyringException("Unparseable JSON data: " + e); } // Loaded data has three attrs, 'db', 'salt' & 'schema_version' salt = (String) obj.get("salt"); long dbSchemaVersion = (Long) obj.get("schema_version"); if (schemaVersion != dbSchemaVersion) { // TODO Handle other versions sanely throw new KeyringException("Incompatible schema version " + dbSchemaVersion); } cryptedDb = (String) obj.get("db"); } /** * Attempt to decrypt the loaded data with the supplied key. If it parses, * the key is good, and loading is complete. If not, it's a bad password. * @throws GeneralSecurityException */ @SuppressWarnings("unchecked") private boolean decryptLoadedData() throws GeneralSecurityException { log("decryptLoadedData()"); JSONObject obj; try { String decryptedJson = decrypt(cryptedDb); obj = (JSONObject) parser.parse(decryptedJson); } catch(ParseException e) { /* Can't parse decrypted data. This is almost always due to a * bad password, but it's possible that the db is corrupt. * Unfortunately, there's no good way to tell the difference... * * TODO Hmmm, we could check to see if the last character is a * closing curly brace... */ return false; } // Clear temp storage cryptedDb = null; log("Depot data loaded"); // We've got our data, pull it apart into usable pieces // TODO What if the decrypted data isn't a Keyring backup? Map<String, JSONObject> rawDb = (Map<String, JSONObject>) obj.get("db"); for (Map.Entry<String, JSONObject> ent : rawDb.entrySet()) { String title = ent.getKey(); JSONObject rawItem = ent.getValue(); db.put(title, new Item(this, rawItem)); } // Handle categories categoriesById = new HashMap<Integer, String>(); categoriesByName = new TreeMap<String, Integer>(); Object tmp = obj.get("categories"); if (null != tmp) { Map<String, String> tmpCats = (Map<String, String>) tmp; for (Map.Entry<String, String> cat : tmpCats.entrySet()) { int id = Integer.parseInt(cat.getKey()); categoriesById.put(id, cat.getValue()); categoriesByName.put(cat.getValue(), id); } } // make sure we always have the "all" and "unfiled" categories setDefaultCategories(); checkData = (String) ((JSONObject) obj.get("crypt")).get("checkData"); // For now, just stash prefs as a JSONObject prefs = (JSONObject) obj.get("prefs"); fullyLoaded = true; log("Depot data processed"); return true; } private Writer getWriter(String outFile) throws IOException, GeneralSecurityException { OutputStream os; if (outFile.equals("-")) { os = System.out; } else { os = new FileOutputStream(new File(outFile)); } OutputStreamWriter writer = new OutputStreamWriter(os, "UTF-8"); return writer; } private void closeWriter(Writer writer, String outFile) throws IOException { if (outFile.equals("-")) { writer.write("\n"); writer.flush(); } else { writer.close(); } } /** * Return ISO date representation of the epoch time * * @param epoch * @param includeTime If true, append HH:mm:ss * @return */ public String formatDate(long epoch, boolean includeTime) { String format; if (includeTime) { format = "yyyy-MM-dd HH:mm:ss ZZZZ"; } else { format = "yyyy-MM-dd"; } return new SimpleDateFormat(format).format(new Date(epoch)); } /** * Export data to the specified file. * * @param outFile Path to the output file * @throws IOException * @throws GeneralSecurityException */ public void save(String outFile) throws IOException, GeneralSecurityException { log("save(" + outFile + ")"); if (outFile.startsWith("http")) { URL url = new URL(outFile); URLConnection urlConn = url.openConnection(); urlConn.setDoInput(true); urlConn.setDoOutput(true); urlConn.setUseCaches(false); urlConn.setRequestProperty ("Content-Type", "application/x-www-form-urlencoded"); DataOutputStream dos = new DataOutputStream (urlConn.getOutputStream()); String message = "data=" + URLEncoder.encode(getExportData().toJSONString(), "UTF-8"); dos.writeBytes(message); dos.flush(); dos.close(); // the server responds by saying // "OK" or "ERROR: blah blah" BufferedReader br = new BufferedReader(new InputStreamReader(urlConn.getInputStream())); String s = br.readLine(); if (! s.equals("OK")) { StringBuilder sb = new StringBuilder(); sb.append("Failed to save to URL '"); sb.append(url); sb.append("': "); while ((s = br.readLine()) != null) { sb.append(s); } throw new IOException(sb.toString()); } br.close(); } else { Writer writer = getWriter(outFile); getExportData().writeJSONString(writer); closeWriter(writer, outFile); } } public void exportToCSV(String outFile) throws IOException, GeneralSecurityException, KeyringException { log("exportToCSV(" + outFile + ")"); Writer writer = getWriter(outFile); CSVPrinter csv = new CSVPrinter(writer); csv.writeln(new String[] {"title", "username", "password", "url", "category", "created", "viewed", "changed", "notes"}); for (Item i : db.values()) { csv.write(i.getTitle()); csv.write(i.getUsername()); csv.write(i.getPass()); csv.write(i.getUrl()); csv.write(i.getCategory()); csv.write(formatDate(i.getCreated(), true)); csv.write(formatDate(i.getViewed(), true)); csv.write(formatDate(i.getChanged(), true)); csv.writeln(i.getNotes()); } closeWriter(writer, outFile); } private void log(String message) { System.err.println(message); } }