1// Copyright 2009 The Android Open Source Project
2
3package android.core;
4
5import android.database.Cursor;
6import android.database.SQLException;
7import android.database.sqlite.SQLiteDatabase;
8import android.database.sqlite.SQLiteOpenHelper;
9import android.util.Log;
10import android.content.ContentValues;
11import android.content.Context;
12
13import org.apache.commons.codec.binary.Base64;
14import org.apache.harmony.xnet.provider.jsse.SSLClientSessionCache;
15
16import java.util.LinkedHashMap;
17import java.util.Map;
18
19import javax.net.ssl.SSLSession;
20
21/**
22 * Hook into harmony SSL cache to persist the SSL sessions.
23 *
24 * Current implementation is suitable for saving a small number of hosts -
25 * like google services. It can be extended with expiration and more features
26 * to support more hosts.
27 *
28 * {@hide}
29 */
30public class DatabaseSessionCache implements SSLClientSessionCache {
31    private static final String TAG = "SslSessionCache";
32    static DatabaseHelper sDefaultDatabaseHelper;
33
34    private DatabaseHelper mDatabaseHelper;
35
36    /**
37     * Table where sessions are stored.
38     */
39    public static final String SSL_CACHE_TABLE = "ssl_sessions";
40
41    private static final String SSL_CACHE_ID = "_id";
42
43    /**
44     * Key is host:port - port is not optional.
45     */
46    private static final String SSL_CACHE_HOSTPORT = "hostport";
47
48    /**
49     * Base64-encoded DER value of the session.
50     */
51    private static final String SSL_CACHE_SESSION = "session";
52
53    /**
54     * Time when the record was added - should be close to the time
55     * of the initial session negotiation.
56     */
57    private static final String SSL_CACHE_TIME_SEC = "time_sec";
58
59    public static final String DATABASE_NAME = "ssl_sessions.db";
60
61    public static final int DATABASE_VERSION = 1;
62
63    /** public for testing
64     */
65    public static final int SSL_CACHE_ID_COL = 0;
66    public static final int SSL_CACHE_HOSTPORT_COL = 1;
67    public static final int SSL_CACHE_SESSION_COL = 2;
68    public static final int SSL_CACHE_TIME_SEC_COL = 3;
69
70    private static final String SAVE_ON_ADD = "save_on_add";
71
72    static boolean sHookInitializationDone = false;
73
74    public static final int MAX_CACHE_SIZE = 256;
75
76    private static final Map<String, byte[]> mExternalCache =
77        new LinkedHashMap<String, byte[]>(MAX_CACHE_SIZE, 0.75f, true) {
78        @Override
79        public boolean removeEldestEntry(
80                Map.Entry<String, byte[]> eldest) {
81            boolean shouldDelete = this.size() > MAX_CACHE_SIZE;
82
83            // TODO: delete from DB
84            return shouldDelete;
85        }
86    };
87    static boolean mNeedsCacheLoad = true;
88
89    public static final String[] PROJECTION = new String[] {
90      SSL_CACHE_ID,
91      SSL_CACHE_HOSTPORT,
92      SSL_CACHE_SESSION,
93      SSL_CACHE_TIME_SEC
94    };
95
96    /**
97     * This class needs to be installed as a hook, if the security property
98     * is set. Getting the right classloader may be fun since we don't use
99     * Provider to get its classloader, but in android this is in same
100     * loader with AndroidHttpClient.
101     *
102     * This constructor will use the default database. You must
103     * call init() before to specify the context used for the database and
104     * check settings.
105     */
106    public DatabaseSessionCache() {
107        Log.v(TAG, "Instance created.");
108        // May be null if caching is disabled - no sessions will be persisted.
109        this.mDatabaseHelper = sDefaultDatabaseHelper;
110    }
111
112    /**
113     * Create a SslSessionCache instance, using the specified context to
114     * initialize the database.
115     *
116     * This constructor will use the default database - created the first
117     * time.
118     *
119     * @param activityContext
120     */
121    public DatabaseSessionCache(Context activityContext) {
122        // Static init - only one initialization will happen.
123        // Each SslSessionCache is using the same DB.
124        init(activityContext);
125        // May be null if caching is disabled - no sessions will be persisted.
126        this.mDatabaseHelper = sDefaultDatabaseHelper;
127    }
128
129    /**
130     * Create a SslSessionCache that uses a specific database.
131     *
132     * @param database
133     */
134    public DatabaseSessionCache(DatabaseHelper database) {
135        this.mDatabaseHelper = database;
136    }
137
138//    public static boolean enabled(Context androidContext) {
139//        String sslCache = Settings.Secure.getString(androidContext.getContentResolver(),
140//                Settings.Secure.SSL_SESSION_CACHE);
141//
142//        if (Log.isLoggable(TAG, Log.DEBUG)) {
143//            Log.d(TAG, "enabled " + sslCache + " " + androidContext.getPackageName());
144//        }
145//
146//        return SAVE_ON_ADD.equals(sslCache);
147//    }
148
149    /**
150     * You must call this method to enable SSL session caching for an app.
151     */
152    public synchronized static void init(Context activityContext) {
153        // It is possible that multiple provider will try to install this hook.
154        // We want a single db per VM.
155        if (sHookInitializationDone) {
156            return;
157        }
158
159
160//        // More values can be added in future to provide different
161//        // behaviours, like 'batch save'.
162//        if (enabled(activityContext)) {
163            Context appContext = activityContext.getApplicationContext();
164            sDefaultDatabaseHelper = new DatabaseHelper(appContext);
165
166            // Set default SSLSocketFactory
167            // The property is defined in the javadocs for javax.net.SSLSocketFactory
168            // (no constant defined there)
169            // This should cover all code using SSLSocketFactory.getDefault(),
170            // including native http client and apache httpclient.
171            // MCS is using its own custom factory - will need special code.
172//            Security.setProperty("ssl.SocketFactory.provider",
173//                    SslSocketFactoryWithCache.class.getName());
174//        }
175
176        // Won't try again.
177        sHookInitializationDone = true;
178    }
179
180    public void putSessionData(SSLSession session, byte[] der) {
181        if (mDatabaseHelper == null) {
182            return;
183        }
184        if (mExternalCache.size() > MAX_CACHE_SIZE) {
185            // remove oldest.
186            Cursor byTime = mDatabaseHelper.getWritableDatabase().query(SSL_CACHE_TABLE,
187                    PROJECTION, null, null, null, null, SSL_CACHE_TIME_SEC);
188            byTime.moveToFirst();
189            // TODO: can I do byTime.deleteRow() ?
190            String hostPort = byTime.getString(SSL_CACHE_HOSTPORT_COL);
191
192            mDatabaseHelper.getWritableDatabase().delete(SSL_CACHE_TABLE,
193                    SSL_CACHE_HOSTPORT + "= ?" , new String[] { hostPort });
194        }
195        // Serialize native session to standard DER encoding
196        long t0 = System.currentTimeMillis();
197
198        String b64 = new String(Base64.encodeBase64(der));
199        String key = session.getPeerHost() + ":" + session.getPeerPort();
200
201        ContentValues values = new ContentValues();
202        values.put(SSL_CACHE_HOSTPORT, key);
203        values.put(SSL_CACHE_SESSION, b64);
204        values.put(SSL_CACHE_TIME_SEC, System.currentTimeMillis() / 1000);
205
206        synchronized (this.getClass()) {
207            mExternalCache.put(key, der);
208
209            try {
210                mDatabaseHelper.getWritableDatabase().insert(SSL_CACHE_TABLE, null /*nullColumnHack */ , values);
211            } catch(SQLException ex) {
212                // Ignore - nothing we can do to recover, and caller shouldn't
213                // be affected.
214                Log.w(TAG, "Ignoring SQL exception when caching session", ex);
215            }
216        }
217        if (Log.isLoggable(TAG, Log.DEBUG)) {
218            long t1 = System.currentTimeMillis();
219            Log.d(TAG, "New SSL session " + session.getPeerHost() +
220                    " DER len: " + der.length + " " + (t1 - t0));
221        }
222
223    }
224
225    public byte[] getSessionData(String host, int port) {
226        // Current (simple) implementation does a single lookup to DB, then saves
227        // all entries to the cache.
228
229        // This works for google services - i.e. small number of certs.
230        // If we extend this to all processes - we should hold a separate cache
231        // or do lookups to DB each time.
232        if (mDatabaseHelper == null) {
233            return null;
234        }
235        synchronized(this.getClass()) {
236            if (mNeedsCacheLoad) {
237                // Don't try to load again, if something is wrong on the first
238                // request it'll likely be wrong each time.
239                mNeedsCacheLoad = false;
240                long t0 = System.currentTimeMillis();
241
242                Cursor cur = null;
243                try {
244                    cur = mDatabaseHelper.getReadableDatabase().query(SSL_CACHE_TABLE, PROJECTION, null,
245                            null, null, null, null);
246                    if (cur.moveToFirst()) {
247                        do {
248                            String hostPort = cur.getString(SSL_CACHE_HOSTPORT_COL);
249                            String value = cur.getString(SSL_CACHE_SESSION_COL);
250
251                            if (hostPort == null || value == null) {
252                                continue;
253                            }
254                            // TODO: blob support ?
255                            byte[] der = Base64.decodeBase64(value.getBytes());
256                            mExternalCache.put(hostPort, der);
257                        } while (cur.moveToNext());
258
259                    }
260                } catch (SQLException ex) {
261                    Log.d(TAG, "Error loading SSL cached entries ", ex);
262                } finally {
263                    if (cur != null) {
264                        cur.close();
265                    }
266                    if (Log.isLoggable(TAG, Log.DEBUG)) {
267                        long t1 = System.currentTimeMillis();
268                        Log.d(TAG, "LOADED CACHED SSL " + (t1 - t0) + " ms");
269                    }
270                }
271            }
272
273            String key = host + ":" + port;
274
275            return mExternalCache.get(key);
276        }
277    }
278
279    public byte[] getSessionData(byte[] id) {
280        // We support client side only - the cache will do nothing on client.
281        return null;
282    }
283
284    /** Visible for testing.
285     */
286    public static class DatabaseHelper extends SQLiteOpenHelper {
287
288        public DatabaseHelper(Context context) {
289            super(context, DATABASE_NAME, null /* factory */, DATABASE_VERSION);
290        }
291
292        @Override
293        public void onCreate(SQLiteDatabase db) {
294            db.execSQL("CREATE TABLE " + SSL_CACHE_TABLE + " (" +
295                    SSL_CACHE_ID + " INTEGER PRIMARY KEY AUTOINCREMENT," +
296                    SSL_CACHE_HOSTPORT + " TEXT UNIQUE ON CONFLICT REPLACE," +
297                    SSL_CACHE_SESSION + " TEXT," +
298                    SSL_CACHE_TIME_SEC + " INTEGER" +
299            ");");
300            db.execSQL("CREATE INDEX ssl_sessions_idx1 ON ssl_sessions (" +
301                    SSL_CACHE_HOSTPORT + ");");
302        }
303
304        @Override
305        public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
306            db.execSQL("DROP TABLE IF EXISTS " + SSL_CACHE_TABLE );
307            onCreate(db);
308        }
309
310    }
311
312}
313