1 /* 2 * Copyright (C) 2013 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.example.android.vault; 18 19 import static com.example.android.vault.EncryptedDocument.DATA_KEY_LENGTH; 20 import static com.example.android.vault.EncryptedDocument.MAC_KEY_LENGTH; 21 import static com.example.android.vault.Utils.closeQuietly; 22 import static com.example.android.vault.Utils.closeWithErrorQuietly; 23 import static com.example.android.vault.Utils.readFully; 24 import static com.example.android.vault.Utils.writeFully; 25 26 import android.content.Context; 27 import android.content.SharedPreferences; 28 import android.database.Cursor; 29 import android.database.MatrixCursor; 30 import android.database.MatrixCursor.RowBuilder; 31 import android.os.Bundle; 32 import android.os.CancellationSignal; 33 import android.os.ParcelFileDescriptor; 34 import android.provider.DocumentsContract; 35 import android.provider.DocumentsContract.Document; 36 import android.provider.DocumentsContract.Root; 37 import android.provider.DocumentsProvider; 38 import android.security.KeyChain; 39 import android.util.Log; 40 41 import org.json.JSONArray; 42 import org.json.JSONException; 43 import org.json.JSONObject; 44 45 import java.io.File; 46 import java.io.FileNotFoundException; 47 import java.io.IOException; 48 import java.nio.charset.StandardCharsets; 49 import java.security.GeneralSecurityException; 50 import java.security.KeyStore; 51 import java.security.SecureRandom; 52 53 import javax.crypto.Mac; 54 import javax.crypto.SecretKey; 55 import javax.crypto.spec.SecretKeySpec; 56 57 /** 58 * Provider that encrypts both metadata and contents of documents stored inside. 59 * Each document is stored as described by {@link EncryptedDocument} with 60 * separate metadata and content sections. Directories are just 61 * {@link EncryptedDocument} instances without a content section, and a list of 62 * child documents included in the metadata section. 63 * <p> 64 * All content is encrypted/decrypted on demand through pipes, using 65 * {@link ParcelFileDescriptor#createReliablePipe()} to detect and recover from 66 * remote crashes and errors. 67 * <p> 68 * Our symmetric encryption key is stored on disk only after using 69 * {@link SecretKeyWrapper} to "wrap" it using another public/private key pair 70 * stored in the platform {@link KeyStore}. This allows us to protect our 71 * symmetric key with hardware-backed keys, if supported. Devices without 72 * hardware support still encrypt their keys while at rest, and the platform 73 * always requires a user to present a PIN, password, or pattern to unlock the 74 * KeyStore before use. 75 */ 76 public class VaultProvider extends DocumentsProvider { 77 public static final String TAG = "Vault"; 78 79 static final String AUTHORITY = "com.example.android.vault.provider"; 80 81 static final String DEFAULT_ROOT_ID = "vault"; 82 static final String DEFAULT_DOCUMENT_ID = "0"; 83 84 /** JSON key storing array of all children documents in a directory. */ 85 private static final String KEY_CHILDREN = "vault:children"; 86 87 /** Key pointing to next available document ID. */ 88 private static final String PREF_NEXT_ID = "next_id"; 89 90 /** Blob used to derive {@link #mDataKey} from our secret key. */ 91 private static final byte[] BLOB_DATA = "DATA".getBytes(StandardCharsets.UTF_8); 92 /** Blob used to derive {@link #mMacKey} from our secret key. */ 93 private static final byte[] BLOB_MAC = "MAC".getBytes(StandardCharsets.UTF_8); 94 95 private static final String[] DEFAULT_ROOT_PROJECTION = new String[] { 96 Root.COLUMN_ROOT_ID, Root.COLUMN_FLAGS, Root.COLUMN_ICON, Root.COLUMN_TITLE, 97 Root.COLUMN_DOCUMENT_ID, Root.COLUMN_AVAILABLE_BYTES, Root.COLUMN_SUMMARY 98 }; 99 100 private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[] { 101 Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME, 102 Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, Document.COLUMN_SIZE, 103 }; 104 105 private static String[] resolveRootProjection(String[] projection) { 106 return projection != null ? projection : DEFAULT_ROOT_PROJECTION; 107 } 108 109 private static String[] resolveDocumentProjection(String[] projection) { 110 return projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION; 111 } 112 113 private final Object mIdLock = new Object(); 114 115 /** 116 * Flag indicating that the {@link SecretKeyWrapper} public/private key is 117 * hardware-backed. A software keystore is more vulnerable to offline 118 * attacks if the device is compromised. 119 */ 120 private boolean mHardwareBacked; 121 122 /** File where wrapped symmetric key is stored. */ 123 private File mKeyFile; 124 /** Directory where all encrypted documents are stored. */ 125 private File mDocumentsDir; 126 127 private SecretKey mDataKey; 128 private SecretKey mMacKey; 129 130 @Override 131 public boolean onCreate() { 132 mHardwareBacked = KeyChain.isBoundKeyAlgorithm("RSA"); 133 134 mKeyFile = new File(getContext().getFilesDir(), "vault.key"); 135 mDocumentsDir = new File(getContext().getFilesDir(), "documents"); 136 mDocumentsDir.mkdirs(); 137 138 try { 139 // Load secret key and ensure our root document is ready. 140 loadOrGenerateKeys(getContext(), mKeyFile); 141 initDocument(Long.parseLong(DEFAULT_DOCUMENT_ID), Document.MIME_TYPE_DIR, null); 142 143 } catch (IOException e) { 144 throw new IllegalStateException(e); 145 } catch (GeneralSecurityException e) { 146 throw new IllegalStateException(e); 147 } 148 149 return true; 150 } 151 152 /** 153 * Used for testing. 154 */ 155 void wipeAllContents() throws IOException, GeneralSecurityException { 156 for (File f : mDocumentsDir.listFiles()) { 157 f.delete(); 158 } 159 160 initDocument(Long.parseLong(DEFAULT_DOCUMENT_ID), Document.MIME_TYPE_DIR, null); 161 } 162 163 /** 164 * Load our symmetric secret key and use it to derive two different data and 165 * MAC keys. The symmetric secret key is stored securely on disk by wrapping 166 * it with a public/private key pair, possibly backed by hardware. 167 */ 168 private void loadOrGenerateKeys(Context context, File keyFile) 169 throws GeneralSecurityException, IOException { 170 final SecretKeyWrapper wrapper = new SecretKeyWrapper(context, TAG); 171 172 // Generate secret key if none exists 173 if (!keyFile.exists()) { 174 final byte[] raw = new byte[DATA_KEY_LENGTH]; 175 new SecureRandom().nextBytes(raw); 176 177 final SecretKey key = new SecretKeySpec(raw, "AES"); 178 final byte[] wrapped = wrapper.wrap(key); 179 180 writeFully(keyFile, wrapped); 181 } 182 183 // Even if we just generated the key, always read it back to ensure we 184 // can read it successfully. 185 final byte[] wrapped = readFully(keyFile); 186 final SecretKey key = wrapper.unwrap(wrapped); 187 188 final Mac mac = Mac.getInstance("HmacSHA256"); 189 mac.init(key); 190 191 // Derive two different keys for encryption and authentication. 192 final byte[] rawDataKey = new byte[DATA_KEY_LENGTH]; 193 final byte[] rawMacKey = new byte[MAC_KEY_LENGTH]; 194 195 System.arraycopy(mac.doFinal(BLOB_DATA), 0, rawDataKey, 0, rawDataKey.length); 196 System.arraycopy(mac.doFinal(BLOB_MAC), 0, rawMacKey, 0, rawMacKey.length); 197 198 mDataKey = new SecretKeySpec(rawDataKey, "AES"); 199 mMacKey = new SecretKeySpec(rawMacKey, "HmacSHA256"); 200 } 201 202 @Override 203 public Cursor queryRoots(String[] projection) throws FileNotFoundException { 204 final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection)); 205 final RowBuilder row = result.newRow(); 206 row.add(Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID); 207 row.add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE | Root.FLAG_LOCAL_ONLY); 208 row.add(Root.COLUMN_TITLE, getContext().getString(R.string.app_label)); 209 row.add(Root.COLUMN_DOCUMENT_ID, DEFAULT_DOCUMENT_ID); 210 row.add(Root.COLUMN_ICON, R.drawable.ic_lock_lock); 211 212 // Notify user in storage UI when key isn't hardware-backed 213 if (!mHardwareBacked) { 214 row.add(Root.COLUMN_SUMMARY, getContext().getString(R.string.info_software)); 215 } 216 217 return result; 218 } 219 220 private EncryptedDocument getDocument(long docId) throws GeneralSecurityException { 221 final File file = new File(mDocumentsDir, String.valueOf(docId)); 222 return new EncryptedDocument(docId, file, mDataKey, mMacKey); 223 } 224 225 /** 226 * Include metadata for a document in the given result cursor. 227 */ 228 private void includeDocument(MatrixCursor result, long docId) 229 throws IOException, GeneralSecurityException { 230 final EncryptedDocument doc = getDocument(docId); 231 if (!doc.getFile().exists()) { 232 throw new FileNotFoundException("Missing document " + docId); 233 } 234 235 final JSONObject meta = doc.readMetadata(); 236 237 int flags = 0; 238 239 final String mimeType = meta.optString(Document.COLUMN_MIME_TYPE); 240 if (Document.MIME_TYPE_DIR.equals(mimeType)) { 241 flags |= Document.FLAG_DIR_SUPPORTS_CREATE; 242 } else { 243 flags |= Document.FLAG_SUPPORTS_WRITE; 244 } 245 flags |= Document.FLAG_SUPPORTS_DELETE; 246 247 final RowBuilder row = result.newRow(); 248 row.add(Document.COLUMN_DOCUMENT_ID, meta.optLong(Document.COLUMN_DOCUMENT_ID)); 249 row.add(Document.COLUMN_DISPLAY_NAME, meta.optString(Document.COLUMN_DISPLAY_NAME)); 250 row.add(Document.COLUMN_SIZE, meta.optLong(Document.COLUMN_SIZE)); 251 row.add(Document.COLUMN_MIME_TYPE, mimeType); 252 row.add(Document.COLUMN_FLAGS, flags); 253 row.add(Document.COLUMN_LAST_MODIFIED, meta.optLong(Document.COLUMN_LAST_MODIFIED)); 254 } 255 256 @Override 257 public String createDocument(String parentDocumentId, String mimeType, String displayName) 258 throws FileNotFoundException { 259 final long parentDocId = Long.parseLong(parentDocumentId); 260 261 // Allocate the next available ID 262 final long childDocId; 263 synchronized (mIdLock) { 264 final SharedPreferences prefs = getContext() 265 .getSharedPreferences(PREF_NEXT_ID, Context.MODE_PRIVATE); 266 childDocId = prefs.getLong(PREF_NEXT_ID, 1); 267 if (!prefs.edit().putLong(PREF_NEXT_ID, childDocId + 1).commit()) { 268 throw new IllegalStateException("Failed to allocate document ID"); 269 } 270 } 271 272 try { 273 initDocument(childDocId, mimeType, displayName); 274 275 // Update parent to reference new child 276 final EncryptedDocument parentDoc = getDocument(parentDocId); 277 final JSONObject parentMeta = parentDoc.readMetadata(); 278 parentMeta.accumulate(KEY_CHILDREN, childDocId); 279 parentDoc.writeMetadataAndContent(parentMeta, null); 280 281 return String.valueOf(childDocId); 282 283 } catch (IOException e) { 284 throw new IllegalStateException(e); 285 } catch (GeneralSecurityException e) { 286 throw new IllegalStateException(e); 287 } catch (JSONException e) { 288 throw new IllegalStateException(e); 289 } 290 } 291 292 /** 293 * Create document on disk, writing an initial metadata section. Someone 294 * might come back later to write contents. 295 */ 296 private void initDocument(long docId, String mimeType, String displayName) 297 throws IOException, GeneralSecurityException { 298 final EncryptedDocument doc = getDocument(docId); 299 if (doc.getFile().exists()) return; 300 301 try { 302 final JSONObject meta = new JSONObject(); 303 meta.put(Document.COLUMN_DOCUMENT_ID, docId); 304 meta.put(Document.COLUMN_MIME_TYPE, mimeType); 305 meta.put(Document.COLUMN_DISPLAY_NAME, displayName); 306 if (Document.MIME_TYPE_DIR.equals(mimeType)) { 307 meta.put(KEY_CHILDREN, new JSONArray()); 308 } 309 310 doc.writeMetadataAndContent(meta, null); 311 } catch (JSONException e) { 312 throw new IOException(e); 313 } 314 } 315 316 @Override 317 public void deleteDocument(String documentId) throws FileNotFoundException { 318 final long docId = Long.parseLong(documentId); 319 320 try { 321 // Delete given document, any children documents under it, and any 322 // references to it from parents. 323 deleteDocumentTree(docId); 324 deleteDocumentReferences(docId); 325 326 } catch (IOException e) { 327 throw new IllegalStateException(e); 328 } catch (GeneralSecurityException e) { 329 throw new IllegalStateException(e); 330 } 331 } 332 333 /** 334 * Recursively delete the given document and any children under it. 335 */ 336 private void deleteDocumentTree(long docId) throws IOException, GeneralSecurityException { 337 final EncryptedDocument doc = getDocument(docId); 338 final JSONObject meta = doc.readMetadata(); 339 try { 340 if (Document.MIME_TYPE_DIR.equals(meta.getString(Document.COLUMN_MIME_TYPE))) { 341 final JSONArray children = meta.getJSONArray(KEY_CHILDREN); 342 for (int i = 0; i < children.length(); i++) { 343 final long childDocId = children.getLong(i); 344 deleteDocumentTree(childDocId); 345 } 346 } 347 } catch (JSONException e) { 348 throw new IOException(e); 349 } 350 351 if (!doc.getFile().delete()) { 352 throw new IOException("Failed to delete " + docId); 353 } 354 } 355 356 /** 357 * Remove any references to the given document, usually when included as a 358 * child of another directory. 359 */ 360 private void deleteDocumentReferences(long docId) { 361 for (String name : mDocumentsDir.list()) { 362 try { 363 final long parentDocId = Long.parseLong(name); 364 final EncryptedDocument parentDoc = getDocument(parentDocId); 365 final JSONObject meta = parentDoc.readMetadata(); 366 367 if (Document.MIME_TYPE_DIR.equals(meta.getString(Document.COLUMN_MIME_TYPE))) { 368 final JSONArray children = meta.getJSONArray(KEY_CHILDREN); 369 if (maybeRemove(children, docId)) { 370 Log.d(TAG, "Removed " + docId + " reference from " + name); 371 parentDoc.writeMetadataAndContent(meta, null); 372 373 getContext().getContentResolver().notifyChange( 374 DocumentsContract.buildChildDocumentsUri(AUTHORITY, name), null, 375 false); 376 } 377 } 378 } catch (NumberFormatException ignored) { 379 } catch (IOException e) { 380 Log.w(TAG, "Failed to examine " + name, e); 381 } catch (GeneralSecurityException e) { 382 Log.w(TAG, "Failed to examine " + name, e); 383 } catch (JSONException e) { 384 Log.w(TAG, "Failed to examine " + name, e); 385 } 386 } 387 } 388 389 @Override 390 public Cursor queryDocument(String documentId, String[] projection) 391 throws FileNotFoundException { 392 final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection)); 393 try { 394 includeDocument(result, Long.parseLong(documentId)); 395 } catch (GeneralSecurityException e) { 396 throw new IllegalStateException(e); 397 } catch (IOException e) { 398 throw new IllegalStateException(e); 399 } 400 return result; 401 } 402 403 @Override 404 public Cursor queryChildDocuments( 405 String parentDocumentId, String[] projection, String sortOrder) 406 throws FileNotFoundException { 407 final ExtrasMatrixCursor result = new ExtrasMatrixCursor( 408 resolveDocumentProjection(projection)); 409 result.setNotificationUri(getContext().getContentResolver(), 410 DocumentsContract.buildChildDocumentsUri(AUTHORITY, parentDocumentId)); 411 412 // Notify user in storage UI when key isn't hardware-backed 413 if (!mHardwareBacked) { 414 result.putString(DocumentsContract.EXTRA_INFO, 415 getContext().getString(R.string.info_software_detail)); 416 } 417 418 try { 419 final EncryptedDocument doc = getDocument(Long.parseLong(parentDocumentId)); 420 final JSONObject meta = doc.readMetadata(); 421 final JSONArray children = meta.getJSONArray(KEY_CHILDREN); 422 for (int i = 0; i < children.length(); i++) { 423 final long docId = children.getLong(i); 424 includeDocument(result, docId); 425 } 426 427 } catch (IOException e) { 428 throw new IllegalStateException(e); 429 } catch (GeneralSecurityException e) { 430 throw new IllegalStateException(e); 431 } catch (JSONException e) { 432 throw new IllegalStateException(e); 433 } 434 435 return result; 436 } 437 438 @Override 439 public ParcelFileDescriptor openDocument( 440 String documentId, String mode, CancellationSignal signal) 441 throws FileNotFoundException { 442 final long docId = Long.parseLong(documentId); 443 444 try { 445 final EncryptedDocument doc = getDocument(docId); 446 if ("r".equals(mode)) { 447 return startRead(doc); 448 } else if ("w".equals(mode) || "wt".equals(mode)) { 449 return startWrite(doc); 450 } else { 451 throw new IllegalArgumentException("Unsupported mode: " + mode); 452 } 453 } catch (IOException e) { 454 throw new IllegalStateException(e); 455 } catch (GeneralSecurityException e) { 456 throw new IllegalStateException(e); 457 } 458 } 459 460 /** 461 * Kick off a thread to handle a read request for the given document. 462 * Internally creates a pipe and returns the read end for returning to a 463 * remote process. 464 */ 465 private ParcelFileDescriptor startRead(final EncryptedDocument doc) throws IOException { 466 final ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createReliablePipe(); 467 final ParcelFileDescriptor readEnd = pipe[0]; 468 final ParcelFileDescriptor writeEnd = pipe[1]; 469 470 new Thread() { 471 @Override 472 public void run() { 473 try { 474 doc.readContent(writeEnd); 475 Log.d(TAG, "Success reading " + doc); 476 closeQuietly(writeEnd); 477 } catch (IOException e) { 478 Log.w(TAG, "Failed reading " + doc, e); 479 closeWithErrorQuietly(writeEnd, e.toString()); 480 } catch (GeneralSecurityException e) { 481 Log.w(TAG, "Failed reading " + doc, e); 482 closeWithErrorQuietly(writeEnd, e.toString()); 483 } 484 } 485 }.start(); 486 487 return readEnd; 488 } 489 490 /** 491 * Kick off a thread to handle a write request for the given document. 492 * Internally creates a pipe and returns the write end for returning to a 493 * remote process. 494 */ 495 private ParcelFileDescriptor startWrite(final EncryptedDocument doc) throws IOException { 496 final ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createReliablePipe(); 497 final ParcelFileDescriptor readEnd = pipe[0]; 498 final ParcelFileDescriptor writeEnd = pipe[1]; 499 500 new Thread() { 501 @Override 502 public void run() { 503 try { 504 final JSONObject meta = doc.readMetadata(); 505 doc.writeMetadataAndContent(meta, readEnd); 506 Log.d(TAG, "Success writing " + doc); 507 closeQuietly(readEnd); 508 } catch (IOException e) { 509 Log.w(TAG, "Failed writing " + doc, e); 510 closeWithErrorQuietly(readEnd, e.toString()); 511 } catch (GeneralSecurityException e) { 512 Log.w(TAG, "Failed writing " + doc, e); 513 closeWithErrorQuietly(readEnd, e.toString()); 514 } 515 } 516 }.start(); 517 518 return writeEnd; 519 } 520 521 /** 522 * Maybe remove the given value from a {@link JSONArray}. 523 * 524 * @return if the array was mutated. 525 */ 526 private static boolean maybeRemove(JSONArray array, long value) throws JSONException { 527 boolean mutated = false; 528 int i = 0; 529 while (i < array.length()) { 530 if (value == array.getLong(i)) { 531 array.remove(i); 532 mutated = true; 533 } else { 534 i++; 535 } 536 } 537 return mutated; 538 } 539 540 /** 541 * Simple extension of {@link MatrixCursor} that makes it easy to provide a 542 * {@link Bundle} of extras. 543 */ 544 private static class ExtrasMatrixCursor extends MatrixCursor { 545 private Bundle mExtras; 546 547 public ExtrasMatrixCursor(String[] columnNames) { 548 super(columnNames); 549 } 550 551 public void putString(String key, String value) { 552 if (mExtras == null) { 553 mExtras = new Bundle(); 554 } 555 mExtras.putString(key, value); 556 } 557 558 @Override 559 public Bundle getExtras() { 560 return mExtras; 561 } 562 } 563 } 564