com.otisbean.keyring.Ring.java Source code

Java tutorial

Introduction

Here is the source code for com.otisbean.keyring.Ring.java

Source

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