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