OSDN Git Service

Pull backup password logic out of BackupManagerService
authorRobert Berry <robertberry@google.com>
Thu, 13 Jul 2017 16:14:00 +0000 (17:14 +0100)
committerRobert Berry <robertberry@google.com>
Fri, 14 Jul 2017 13:51:16 +0000 (14:51 +0100)
This makes it easier to follow what's going on. Also makes it easy for us to
add unit tests for this component.

Bug: 36850431
Test: adb shell am instrument -w -e package com.android.server.backup com.android.frameworks.servicestests/android.support.test.runner.AndroidJUnitRunner
Change-Id: Ifb85f1573bcfb7da26febed35d49454614b5d975

services/backup/java/com/android/server/backup/BackupPasswordManager.java [new file with mode: 0644]
services/backup/java/com/android/server/backup/RefactoredBackupManagerService.java
services/backup/java/com/android/server/backup/fullbackup/PerformAdbBackupTask.java
services/backup/java/com/android/server/backup/restore/PerformAdbRestoreTask.java
services/backup/java/com/android/server/backup/utils/DataStreamCodec.java [new file with mode: 0644]
services/backup/java/com/android/server/backup/utils/DataStreamFileCodec.java [new file with mode: 0644]
services/backup/java/com/android/server/backup/utils/PasswordUtils.java
services/tests/servicestests/src/com/android/server/backup/BackupPasswordManagerTest.java [new file with mode: 0644]
services/tests/servicestests/src/com/android/server/backup/utils/DataStreamFileCodecTest.java [new file with mode: 0644]

diff --git a/services/backup/java/com/android/server/backup/BackupPasswordManager.java b/services/backup/java/com/android/server/backup/BackupPasswordManager.java
new file mode 100644 (file)
index 0000000..ee7651b
--- /dev/null
@@ -0,0 +1,307 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.server.backup;
+
+import android.content.Context;
+import android.util.Slog;
+
+import com.android.server.backup.utils.DataStreamFileCodec;
+import com.android.server.backup.utils.DataStreamCodec;
+import com.android.server.backup.utils.PasswordUtils;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.security.SecureRandom;
+
+/**
+ * Manages persisting and verifying backup passwords.
+ *
+ * <p>Does not persist the password itself, but persists a PBKDF2 hash with a randomly chosen (also
+ * persisted) salt. Validation is performed by running the challenge text through the same
+ * PBKDF2 cycle with the persisted salt, and checking the hashes match.
+ *
+ * @see PasswordUtils for the hashing algorithm.
+ */
+public final class BackupPasswordManager {
+    private static final String TAG = "BackupPasswordManager";
+    private static final boolean DEBUG = false;
+
+    private static final int BACKUP_PW_FILE_VERSION = 2;
+    private static final int DEFAULT_PW_FILE_VERSION = 1;
+
+    private static final String PASSWORD_VERSION_FILE_NAME = "pwversion";
+    private static final String PASSWORD_HASH_FILE_NAME = "pwhash";
+
+    // See https://android-developers.googleblog.com/2013/12/changes-to-secretkeyfactory-api-in.html
+    public static final String PBKDF_CURRENT = "PBKDF2WithHmacSHA1";
+    public static final String PBKDF_FALLBACK = "PBKDF2WithHmacSHA1And8bit";
+
+    private final SecureRandom mRng;
+    private final Context mContext;
+    private final File mBaseStateDir;
+
+    private String mPasswordHash;
+    private int mPasswordVersion;
+    private byte[] mPasswordSalt;
+
+    /**
+     * Creates an instance enforcing permissions using the {@code context} and persisting password
+     * data within the {@code baseStateDir}.
+     *
+     * @param context The context, for enforcing permissions around setting the password.
+     * @param baseStateDir A directory within which to persist password data.
+     * @param secureRandom Random number generator with which to generate password salts.
+     */
+    BackupPasswordManager(Context context, File baseStateDir, SecureRandom secureRandom) {
+        mContext = context;
+        mRng = secureRandom;
+        mBaseStateDir = baseStateDir;
+        loadStateFromFilesystem();
+    }
+
+    /**
+     * Returns {@code true} if a password for backup is set.
+     *
+     * @throws SecurityException If caller does not have {@link android.Manifest.permission#BACKUP}
+     *   permission.
+     */
+    boolean hasBackupPassword() {
+        mContext.enforceCallingOrSelfPermission(android.Manifest.permission.BACKUP,
+                "hasBackupPassword");
+        return mPasswordHash != null && mPasswordHash.length() > 0;
+    }
+
+    /**
+     * Returns {@code true} if {@code password} matches the persisted password.
+     *
+     * @throws SecurityException If caller does not have {@link android.Manifest.permission#BACKUP}
+     *   permission.
+     */
+    boolean backupPasswordMatches(String password) {
+        if (hasBackupPassword() && !passwordMatchesSaved(password)) {
+            if (DEBUG) Slog.w(TAG, "Backup password mismatch; aborting");
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * Sets the new password, given a correct current password.
+     *
+     * @throws SecurityException If caller does not have {@link android.Manifest.permission#BACKUP}
+     *   permission.
+     * @return {@code true} if has permission to set the password, {@code currentPassword}
+     *   matches the currently persisted password, and is able to persist {@code newPassword}.
+     */
+    boolean setBackupPassword(String currentPassword, String newPassword) {
+        mContext.enforceCallingOrSelfPermission(android.Manifest.permission.BACKUP,
+                "setBackupPassword");
+
+        if (!passwordMatchesSaved(currentPassword)) {
+            return false;
+        }
+
+        // Snap up to latest password file version.
+        try {
+            getPasswordVersionFileCodec().serialize(BACKUP_PW_FILE_VERSION);
+            mPasswordVersion = BACKUP_PW_FILE_VERSION;
+        } catch (IOException e) {
+            Slog.e(TAG, "Unable to write backup pw version; password not changed");
+            return false;
+        }
+
+        if (newPassword == null || newPassword.isEmpty()) {
+            return clearPassword();
+        }
+
+        try {
+            byte[] salt = randomSalt();
+            String newPwHash = PasswordUtils.buildPasswordHash(
+                    PBKDF_CURRENT, newPassword, salt, PasswordUtils.PBKDF2_HASH_ROUNDS);
+
+            getPasswordHashFileCodec().serialize(new BackupPasswordHash(newPwHash, salt));
+            mPasswordHash = newPwHash;
+            mPasswordSalt = salt;
+            return true;
+        } catch (IOException e) {
+            Slog.e(TAG, "Unable to set backup password");
+        }
+        return false;
+    }
+
+    /**
+     * Returns {@code true} if should try salting using the older PBKDF algorithm.
+     *
+     * <p>This is {@code true} for v1 files.
+     */
+    private boolean usePbkdf2Fallback() {
+        return mPasswordVersion < BACKUP_PW_FILE_VERSION;
+    }
+
+    /**
+     * Deletes the current backup password.
+     *
+     * @return {@code true} if successful.
+     */
+    private boolean clearPassword() {
+        File passwordHashFile = getPasswordHashFile();
+        if (passwordHashFile.exists() && !passwordHashFile.delete()) {
+            Slog.e(TAG, "Unable to clear backup password");
+            return false;
+        }
+
+        mPasswordHash = null;
+        mPasswordSalt = null;
+        return true;
+    }
+
+    /**
+     * Sets the password hash, salt, and version in the object from what has been persisted to the
+     * filesystem.
+     */
+    private void loadStateFromFilesystem() {
+        try {
+            mPasswordVersion = getPasswordVersionFileCodec().deserialize();
+        } catch (IOException e) {
+            Slog.e(TAG, "Unable to read backup pw version");
+            mPasswordVersion = DEFAULT_PW_FILE_VERSION;
+        }
+
+        try {
+            BackupPasswordHash hash = getPasswordHashFileCodec().deserialize();
+            mPasswordHash = hash.hash;
+            mPasswordSalt = hash.salt;
+        } catch (IOException e) {
+            Slog.e(TAG, "Unable to read saved backup pw hash");
+        }
+    }
+
+    /**
+     * Whether the candidate password matches the current password. If the persisted password is an
+     * older version, attempts hashing using the older algorithm.
+     *
+     * @param candidatePassword The password to try.
+     * @return {@code true} if the passwords match.
+     */
+    private boolean passwordMatchesSaved(String candidatePassword) {
+        return passwordMatchesSaved(PBKDF_CURRENT, candidatePassword)
+                || (usePbkdf2Fallback() && passwordMatchesSaved(PBKDF_FALLBACK, candidatePassword));
+    }
+
+    /**
+     * Returns {@code true} if the candidate password is correct.
+     *
+     * @param algorithm The algorithm used to hash passwords.
+     * @param candidatePassword The candidate password to compare to the current password.
+     * @return {@code true} if the candidate password matched the saved password.
+     */
+    private boolean passwordMatchesSaved(String algorithm, String candidatePassword) {
+        if (mPasswordHash == null) {
+            return candidatePassword == null || candidatePassword.equals("");
+        } else if (candidatePassword == null || candidatePassword.length() == 0) {
+            // The current password is not zero-length, but the candidate password is.
+            return false;
+        } else {
+            String candidatePasswordHash = PasswordUtils.buildPasswordHash(
+                    algorithm, candidatePassword, mPasswordSalt, PasswordUtils.PBKDF2_HASH_ROUNDS);
+            return mPasswordHash.equalsIgnoreCase(candidatePasswordHash);
+        }
+    }
+
+    private byte[] randomSalt() {
+        int bitsPerByte = 8;
+        byte[] array = new byte[PasswordUtils.PBKDF2_SALT_SIZE / bitsPerByte];
+        mRng.nextBytes(array);
+        return array;
+    }
+
+    private DataStreamFileCodec<Integer> getPasswordVersionFileCodec() {
+        return new DataStreamFileCodec<>(
+                new File(mBaseStateDir, PASSWORD_VERSION_FILE_NAME),
+                new PasswordVersionFileCodec());
+    }
+
+    private DataStreamFileCodec<BackupPasswordHash> getPasswordHashFileCodec() {
+        return new DataStreamFileCodec<>(getPasswordHashFile(), new PasswordHashFileCodec());
+    }
+
+    private File getPasswordHashFile() {
+        return new File(mBaseStateDir, PASSWORD_HASH_FILE_NAME);
+    }
+
+    /**
+     * Container class for a PBKDF hash and the salt used to create the hash.
+     */
+    private static final class BackupPasswordHash {
+        public String hash;
+        public byte[] salt;
+
+        BackupPasswordHash(String hash, byte[] salt) {
+            this.hash = hash;
+            this.salt = salt;
+        }
+    }
+
+    /**
+     * The password version file contains a single 32-bit integer.
+     */
+    private static final class PasswordVersionFileCodec implements
+            DataStreamCodec<Integer> {
+        @Override
+        public void serialize(Integer integer, DataOutputStream dataOutputStream)
+                throws IOException {
+            dataOutputStream.write(integer);
+        }
+
+        @Override
+        public Integer deserialize(DataInputStream dataInputStream) throws IOException {
+            return dataInputStream.readInt();
+        }
+    }
+
+    /**
+     * The passwords hash file contains
+     *
+     * <ul>
+     *     <li>A 32-bit integer representing the number of bytes in the salt;
+     *     <li>The salt bytes;
+     *     <li>A UTF-8 string of the hash.
+     * </ul>
+     */
+    private static final class PasswordHashFileCodec implements
+            DataStreamCodec<BackupPasswordHash> {
+        @Override
+        public void serialize(BackupPasswordHash backupPasswordHash,
+                DataOutputStream dataOutputStream) throws IOException {
+            dataOutputStream.writeInt(backupPasswordHash.salt.length);
+            dataOutputStream.write(backupPasswordHash.salt);
+            dataOutputStream.writeUTF(backupPasswordHash.hash);
+        }
+
+        @Override
+        public BackupPasswordHash deserialize(
+                DataInputStream dataInputStream) throws IOException {
+            int saltLen = dataInputStream.readInt();
+            byte[] salt = new byte[saltLen];
+            dataInputStream.readFully(salt);
+            String hash = dataInputStream.readUTF();
+            return new BackupPasswordHash(hash, salt);
+        }
+    }
+}
index 7e28f61..674e972 100644 (file)
@@ -117,13 +117,11 @@ import com.android.server.backup.restore.PerformUnifiedRestoreTask;
 import com.android.server.backup.utils.AppBackupUtils;
 import com.android.server.backup.utils.BackupManagerMonitorUtils;
 import com.android.server.backup.utils.BackupObserverUtils;
-import com.android.server.backup.utils.PasswordUtils;
 import com.android.server.power.BatterySaverPolicy.ServiceType;
 
 import libcore.io.IoUtils;
 
 import java.io.BufferedInputStream;
-import java.io.BufferedOutputStream;
 import java.io.ByteArrayOutputStream;
 import java.io.DataInputStream;
 import java.io.DataOutputStream;
@@ -135,7 +133,6 @@ import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
-import java.io.OutputStream;
 import java.io.PrintWriter;
 import java.io.RandomAccessFile;
 import java.security.SecureRandom;
@@ -169,10 +166,6 @@ public class RefactoredBackupManagerService implements BackupManagerServiceInter
     // with U+FF00 or higher for system use).
     public static final String KEY_WIDGET_STATE = "\uffed\uffedwidget";
 
-    // Historical and current algorithm names
-    public static final String PBKDF_CURRENT = "PBKDF2WithHmacSHA1";
-    public static final String PBKDF_FALLBACK = "PBKDF2WithHmacSHA1And8bit";
-
     // Name and current contents version of the full-backup manifest file
     //
     // Manifest version history:
@@ -190,7 +183,6 @@ public class RefactoredBackupManagerService implements BackupManagerServiceInter
     // 5 : added support for key-value packages
     public static final int BACKUP_FILE_VERSION = 5;
     public static final String BACKUP_FILE_HEADER_MAGIC = "ANDROID BACKUP\n";
-    private static final int BACKUP_PW_FILE_VERSION = 2;
     public static final String BACKUP_METADATA_FILENAME = "_meta";
     public static final int BACKUP_METADATA_VERSION = 1;
     public static final int BACKUP_WIDGET_METADATA_TOKEN = 0x01FFED01;
@@ -283,6 +275,8 @@ public class RefactoredBackupManagerService implements BackupManagerServiceInter
     private final Object mClearDataLock = new Object();
     private volatile boolean mClearingData;
 
+    private final BackupPasswordManager mBackupPasswordManager;
+
     @GuardedBy("mPendingRestores")
     private boolean mIsRestoreInProgress;
     @GuardedBy("mPendingRestores")
@@ -632,18 +626,7 @@ public class RefactoredBackupManagerService implements BackupManagerServiceInter
     private File mJournalDir;
     private File mJournal;
 
-    // Backup password, if any, and the file where it's saved.  What is stored is not the
-    // password text itself; it's the result of a PBKDF2 hash with a randomly chosen (but
-    // persisted) salt.  Validation is performed by running the challenge text through the
-    // same PBKDF2 cycle with the persisted salt; if the resulting derived key string matches
-    // the saved hash string, then the challenge text matches the originally supplied
-    // password text.
     private final SecureRandom mRng = new SecureRandom();
-    private String mPasswordHash;
-    private File mPasswordHashFile;
-    private int mPasswordVersion;
-    private File mPasswordVersionFile;
-    private byte[] mPasswordSalt;
 
     // Keep a log of all the apps we've ever backed up, and what the
     // dataset tokens are for both the current backup dataset and
@@ -745,52 +728,7 @@ public class RefactoredBackupManagerService implements BackupManagerServiceInter
         // This dir on /cache is managed directly in init.rc
         mDataDir = new File(Environment.getDownloadCacheDirectory(), "backup_stage");
 
-        mPasswordVersion = 1;       // unless we hear otherwise
-        mPasswordVersionFile = new File(mBaseStateDir, "pwversion");
-        if (mPasswordVersionFile.exists()) {
-            FileInputStream fin = null;
-            DataInputStream in = null;
-            try {
-                fin = new FileInputStream(mPasswordVersionFile);
-                in = new DataInputStream(fin);
-                mPasswordVersion = in.readInt();
-            } catch (IOException e) {
-                Slog.e(TAG, "Unable to read backup pw version");
-            } finally {
-                try {
-                    if (in != null) in.close();
-                    if (fin != null) fin.close();
-                } catch (IOException e) {
-                    Slog.w(TAG, "Error closing pw version files");
-                }
-            }
-        }
-
-        mPasswordHashFile = new File(mBaseStateDir, "pwhash");
-        if (mPasswordHashFile.exists()) {
-            FileInputStream fin = null;
-            DataInputStream in = null;
-            try {
-                fin = new FileInputStream(mPasswordHashFile);
-                in = new DataInputStream(new BufferedInputStream(fin));
-                // integer length of the salt array, followed by the salt,
-                // then the hex pw hash string
-                int saltLen = in.readInt();
-                byte[] salt = new byte[saltLen];
-                in.readFully(salt);
-                mPasswordHash = in.readUTF();
-                mPasswordSalt = salt;
-            } catch (IOException e) {
-                Slog.e(TAG, "Unable to read saved backup pw hash");
-            } finally {
-                try {
-                    if (in != null) in.close();
-                    if (fin != null) fin.close();
-                } catch (IOException e) {
-                    Slog.w(TAG, "Unable to close streams");
-                }
-            }
-        }
+        mBackupPasswordManager = new BackupPasswordManager(mContext, mBaseStateDir, mRng);
 
         // Alarm receivers for scheduled backups & initialization operations
         mRunBackupReceiver = new RunBackupReceiver(this);
@@ -1146,128 +1084,18 @@ public class RefactoredBackupManagerService implements BackupManagerServiceInter
         return array;
     }
 
-    private boolean passwordMatchesSaved(String algorithm, String candidatePw, int rounds) {
-        if (mPasswordHash == null) {
-            // no current password case -- require that 'currentPw' be null or empty
-            if (candidatePw == null || "".equals(candidatePw)) {
-                return true;
-            } // else the non-empty candidate does not match the empty stored pw
-        } else {
-            // hash the stated current pw and compare to the stored one
-            if (candidatePw != null && candidatePw.length() > 0) {
-                String currentPwHash = PasswordUtils.buildPasswordHash(algorithm, candidatePw,
-                        mPasswordSalt,
-                        rounds);
-                if (mPasswordHash.equalsIgnoreCase(currentPwHash)) {
-                    // candidate hash matches the stored hash -- the password matches
-                    return true;
-                }
-            } // else the stored pw is nonempty but the candidate is empty; no match
-        }
-        return false;
-    }
-
     @Override
     public boolean setBackupPassword(String currentPw, String newPw) {
-        mContext.enforceCallingOrSelfPermission(android.Manifest.permission.BACKUP,
-                "setBackupPassword");
-
-        // When processing v1 passwords we may need to try two different PBKDF2 checksum regimes
-        final boolean pbkdf2Fallback = (mPasswordVersion < BACKUP_PW_FILE_VERSION);
-
-        // If the supplied pw doesn't hash to the the saved one, fail.  The password
-        // might be caught in the legacy crypto mismatch; verify that too.
-        if (!passwordMatchesSaved(PBKDF_CURRENT, currentPw, PasswordUtils.PBKDF2_HASH_ROUNDS)
-                && !(pbkdf2Fallback && passwordMatchesSaved(PBKDF_FALLBACK,
-                currentPw, PasswordUtils.PBKDF2_HASH_ROUNDS))) {
-            return false;
-        }
-
-        // Snap up to current on the pw file version
-        mPasswordVersion = BACKUP_PW_FILE_VERSION;
-        FileOutputStream pwFout = null;
-        DataOutputStream pwOut = null;
-        try {
-            pwFout = new FileOutputStream(mPasswordVersionFile);
-            pwOut = new DataOutputStream(pwFout);
-            pwOut.writeInt(mPasswordVersion);
-        } catch (IOException e) {
-            Slog.e(TAG, "Unable to write backup pw version; password not changed");
-            return false;
-        } finally {
-            try {
-                if (pwOut != null) pwOut.close();
-                if (pwFout != null) pwFout.close();
-            } catch (IOException e) {
-                Slog.w(TAG, "Unable to close pw version record");
-            }
-        }
-
-        // Clearing the password is okay
-        if (newPw == null || newPw.isEmpty()) {
-            if (mPasswordHashFile.exists()) {
-                if (!mPasswordHashFile.delete()) {
-                    // Unable to delete the old pw file, so fail
-                    Slog.e(TAG, "Unable to clear backup password");
-                    return false;
-                }
-            }
-            mPasswordHash = null;
-            mPasswordSalt = null;
-            return true;
-        }
-
-        try {
-            // Okay, build the hash of the new backup password
-            byte[] salt = randomBytes(PasswordUtils.PBKDF2_SALT_SIZE);
-            String newPwHash = PasswordUtils.buildPasswordHash(PBKDF_CURRENT, newPw, salt,
-                    PasswordUtils.PBKDF2_HASH_ROUNDS);
-
-            OutputStream pwf = null, buffer = null;
-            DataOutputStream out = null;
-            try {
-                pwf = new FileOutputStream(mPasswordHashFile);
-                buffer = new BufferedOutputStream(pwf);
-                out = new DataOutputStream(buffer);
-                // integer length of the salt array, followed by the salt,
-                // then the hex pw hash string
-                out.writeInt(salt.length);
-                out.write(salt);
-                out.writeUTF(newPwHash);
-                out.flush();
-                mPasswordHash = newPwHash;
-                mPasswordSalt = salt;
-                return true;
-            } finally {
-                if (out != null) out.close();
-                if (buffer != null) buffer.close();
-                if (pwf != null) pwf.close();
-            }
-        } catch (IOException e) {
-            Slog.e(TAG, "Unable to set backup password");
-        }
-        return false;
+        return mBackupPasswordManager.setBackupPassword(currentPw, newPw);
     }
 
     @Override
     public boolean hasBackupPassword() {
-        mContext.enforceCallingOrSelfPermission(android.Manifest.permission.BACKUP,
-                "hasBackupPassword");
-
-        return mPasswordHash != null && mPasswordHash.length() > 0;
+        return mBackupPasswordManager.hasBackupPassword();
     }
 
     public boolean backupPasswordMatches(String currentPw) {
-        if (hasBackupPassword()) {
-            final boolean pbkdf2Fallback = (mPasswordVersion < BACKUP_PW_FILE_VERSION);
-            if (!passwordMatchesSaved(PBKDF_CURRENT, currentPw, PasswordUtils.PBKDF2_HASH_ROUNDS)
-                    && !(pbkdf2Fallback && passwordMatchesSaved(PBKDF_FALLBACK,
-                    currentPw, PasswordUtils.PBKDF2_HASH_ROUNDS))) {
-                if (DEBUG) Slog.w(TAG, "Backup password mismatch; aborting");
-                return false;
-            }
-        }
-        return true;
+        return mBackupPasswordManager.backupPasswordMatches(currentPw);
     }
 
     // Maintain persistent state around whether need to do an initialize operation.
index 007d930..804e92c 100644 (file)
 
 package com.android.server.backup.fullbackup;
 
+import static com.android.server.backup.BackupPasswordManager.PBKDF_CURRENT;
 import static com.android.server.backup.RefactoredBackupManagerService.BACKUP_FILE_HEADER_MAGIC;
 import static com.android.server.backup.RefactoredBackupManagerService.BACKUP_FILE_VERSION;
 import static com.android.server.backup.RefactoredBackupManagerService.DEBUG;
 import static com.android.server.backup.RefactoredBackupManagerService.MORE_DEBUG;
-import static com.android.server.backup.RefactoredBackupManagerService.PBKDF_CURRENT;
 import static com.android.server.backup.RefactoredBackupManagerService.SHARED_BACKUP_AGENT_PACKAGE;
 import static com.android.server.backup.RefactoredBackupManagerService.TAG;
 
index b1d6afc..62ae065 100644 (file)
@@ -16,6 +16,8 @@
 
 package com.android.server.backup.restore;
 
+import static com.android.server.backup.BackupPasswordManager.PBKDF_CURRENT;
+import static com.android.server.backup.BackupPasswordManager.PBKDF_FALLBACK;
 import static com.android.server.backup.RefactoredBackupManagerService.BACKUP_FILE_HEADER_MAGIC;
 import static com.android.server.backup.RefactoredBackupManagerService.BACKUP_FILE_VERSION;
 import static com.android.server.backup.RefactoredBackupManagerService.BACKUP_MANIFEST_FILENAME;
@@ -23,8 +25,6 @@ import static com.android.server.backup.RefactoredBackupManagerService.BACKUP_ME
 import static com.android.server.backup.RefactoredBackupManagerService.DEBUG;
 import static com.android.server.backup.RefactoredBackupManagerService.MORE_DEBUG;
 import static com.android.server.backup.RefactoredBackupManagerService.OP_TYPE_RESTORE_WAIT;
-import static com.android.server.backup.RefactoredBackupManagerService.PBKDF_CURRENT;
-import static com.android.server.backup.RefactoredBackupManagerService.PBKDF_FALLBACK;
 import static com.android.server.backup.RefactoredBackupManagerService.SETTINGS_PACKAGE;
 import static com.android.server.backup.RefactoredBackupManagerService.SHARED_BACKUP_AGENT_PACKAGE;
 import static com.android.server.backup.RefactoredBackupManagerService.TAG;
diff --git a/services/backup/java/com/android/server/backup/utils/DataStreamCodec.java b/services/backup/java/com/android/server/backup/utils/DataStreamCodec.java
new file mode 100644 (file)
index 0000000..b1e226d
--- /dev/null
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.server.backup.utils;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.IOException;
+
+/**
+ * Implements how to serialize a {@code T} to a {@link DataOutputStream} and how to deserialize a
+ * {@code T} from a {@link DataInputStream}.
+ *
+ * @param <T> Type of object to be serialized / deserialized.
+ */
+public interface DataStreamCodec<T> {
+    /**
+     * Serializes {@code t} to {@code dataOutputStream}.
+     */
+    void serialize(T t, DataOutputStream dataOutputStream) throws IOException;
+
+    /**
+     * Deserializes {@code t} from {@code dataInputStream}.
+     */
+    T deserialize(DataInputStream dataInputStream) throws IOException;
+}
+
diff --git a/services/backup/java/com/android/server/backup/utils/DataStreamFileCodec.java b/services/backup/java/com/android/server/backup/utils/DataStreamFileCodec.java
new file mode 100644 (file)
index 0000000..7753b03
--- /dev/null
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.server.backup.utils;
+
+import java.io.BufferedOutputStream;
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+
+/**
+ * Provides an interface for serializing an object to a file and deserializing it back again.
+ *
+ * <p>Serialization logic is implemented as a {@link DataStreamCodec}.
+ *
+ * @param <T> The type of object to serialize / deserialize.
+ */
+public final class DataStreamFileCodec<T> {
+    private final File mFile;
+    private final DataStreamCodec<T> mCodec;
+
+    /**
+     * Constructs an instance to serialize to or deserialize from the given file, with the given
+     * serialization / deserialization strategy.
+     */
+    public DataStreamFileCodec(File file, DataStreamCodec<T> codec) {
+        mFile = file;
+        mCodec = codec;
+    }
+
+    /**
+     * Deserializes a {@code T} from the file, automatically closing input streams.
+     *
+     * @return The deserialized object.
+     * @throws IOException if an IO error occurred.
+     */
+    public T deserialize() throws IOException {
+        try (
+            FileInputStream fileInputStream = new FileInputStream(mFile);
+            DataInputStream dataInputStream = new DataInputStream(fileInputStream)
+        ) {
+            return mCodec.deserialize(dataInputStream);
+        }
+    }
+
+    /**
+     * Serializes {@code t} to the file, automatically flushing and closing output streams.
+     *
+     * @param t The object to serialize.
+     * @throws IOException if an IO error occurs.
+     */
+    public void serialize(T t) throws IOException {
+        try (
+            FileOutputStream fileOutputStream = new FileOutputStream(mFile);
+            BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(fileOutputStream);
+            DataOutputStream dataOutputStream = new DataOutputStream(bufferedOutputStream)
+        ) {
+            mCodec.serialize(t, dataOutputStream);
+            dataOutputStream.flush();
+        }
+    }
+}
index 12fc927..9c5e283 100644 (file)
@@ -123,8 +123,7 @@ public class PasswordUtils {
             int rounds) {
         try {
             SecretKeyFactory keyFactory = SecretKeyFactory.getInstance(algorithm);
-            KeySpec
-                    ks = new PBEKeySpec(pwArray, salt, rounds, PBKDF2_KEY_SIZE);
+            KeySpec ks = new PBEKeySpec(pwArray, salt, rounds, PBKDF2_KEY_SIZE);
             return keyFactory.generateSecret(ks);
         } catch (InvalidKeySpecException e) {
             Slog.e(TAG, "Invalid key spec for PBKDF2!");
diff --git a/services/tests/servicestests/src/com/android/server/backup/BackupPasswordManagerTest.java b/services/tests/servicestests/src/com/android/server/backup/BackupPasswordManagerTest.java
new file mode 100644 (file)
index 0000000..04c0251
--- /dev/null
@@ -0,0 +1,232 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.server.backup;
+
+import static com.android.server.testutis.TestUtils.assertExpectException;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doThrow;
+
+import android.content.Context;
+import android.platform.test.annotations.Presubmit;
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+
+import com.android.server.backup.utils.PasswordUtils;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.security.SecureRandom;
+
+@SmallTest
+@Presubmit
+@RunWith(AndroidJUnit4.class)
+public class BackupPasswordManagerTest {
+    private static final String PASSWORD_VERSION_FILE_NAME = "pwversion";
+    private static final String PASSWORD_HASH_FILE_NAME = "pwhash";
+    private static final String V1_HASH_ALGORITHM = "PBKDF2WithHmacSHA1And8bit";
+
+    @Rule public TemporaryFolder mTemporaryFolder = new TemporaryFolder();
+
+    @Mock private Context mContext;
+
+    private File mStateFolder;
+    private BackupPasswordManager mPasswordManager;
+
+    @Before
+    public void setUp() throws Exception {
+        MockitoAnnotations.initMocks(this);
+        mStateFolder = mTemporaryFolder.newFolder();
+        mPasswordManager = new BackupPasswordManager(mContext, mStateFolder, new SecureRandom());
+    }
+
+    @Test
+    public void hasBackupPassword_isFalseIfFileDoesNotExist() {
+        assertThat(mPasswordManager.hasBackupPassword()).isFalse();
+    }
+
+    @Test
+    public void hasBackupPassword_isTrueIfFileExists() throws Exception {
+        mPasswordManager.setBackupPassword(null, "password1234");
+        assertThat(mPasswordManager.hasBackupPassword()).isTrue();
+    }
+
+    @Test
+    public void hasBackupPassword_throwsSecurityExceptionIfLacksPermission() {
+        setDoesNotHavePermission();
+
+        assertExpectException(
+                SecurityException.class,
+                /* expectedExceptionMessageRegex */ null,
+                () -> mPasswordManager.hasBackupPassword());
+    }
+
+    @Test
+    public void backupPasswordMatches_isTrueIfNoPassword() {
+        assertThat(mPasswordManager.backupPasswordMatches("anything")).isTrue();
+    }
+
+    @Test
+    public void backupPasswordMatches_isTrueForSamePassword() {
+        String password = "password1234";
+        mPasswordManager.setBackupPassword(null, password);
+        assertThat(mPasswordManager.backupPasswordMatches(password)).isTrue();
+    }
+
+    @Test
+    public void backupPasswordMatches_isFalseForDifferentPassword() {
+        mPasswordManager.setBackupPassword(null, "shiba");
+        assertThat(mPasswordManager.backupPasswordMatches("corgi")).isFalse();
+    }
+
+    @Test
+    public void backupPasswordMatches_worksForV1HashIfVersionIsV1() throws Exception {
+        String password = "corgi\uFFFF";
+        writePasswordVersionToFile(1);
+        writeV1HashToFile(password, saltFixture());
+
+        // Reconstruct so it reloads from filesystem
+        mPasswordManager = new BackupPasswordManager(mContext, mStateFolder, new SecureRandom());
+
+        assertThat(mPasswordManager.backupPasswordMatches(password)).isTrue();
+    }
+
+    @Test
+    public void backupPasswordMatches_failsForV1HashIfVersionIsV2() throws Exception {
+        // The algorithms produce identical hashes except if the password contains higher-order
+        // unicode. See
+        // https://android-developers.googleblog.com/2013/12/changes-to-secretkeyfactory-api-in.html
+        String password = "corgi\uFFFF";
+        writePasswordVersionToFile(2);
+        writeV1HashToFile(password, saltFixture());
+
+        // Reconstruct so it reloads from filesystem
+        mPasswordManager = new BackupPasswordManager(mContext, mStateFolder, new SecureRandom());
+
+        assertThat(mPasswordManager.backupPasswordMatches(password)).isFalse();
+    }
+
+    @Test
+    public void backupPasswordMatches_throwsSecurityExceptionIfLacksPermission() {
+        setDoesNotHavePermission();
+
+        assertExpectException(
+                SecurityException.class,
+                /* expectedExceptionMessageRegex */ null,
+                () -> mPasswordManager.backupPasswordMatches("password123"));
+    }
+
+    @Test
+    public void setBackupPassword_persistsPasswordToFile() {
+        String password = "shiba";
+
+        mPasswordManager.setBackupPassword(null, password);
+
+        BackupPasswordManager newManager = new BackupPasswordManager(
+                mContext, mStateFolder, new SecureRandom());
+        assertThat(newManager.backupPasswordMatches(password)).isTrue();
+    }
+
+    @Test
+    public void setBackupPassword_failsIfCurrentPasswordIsWrong() {
+        String secondPassword = "second password";
+        mPasswordManager.setBackupPassword(null, "first password");
+
+        boolean result = mPasswordManager.setBackupPassword(
+                "incorrect pass", secondPassword);
+
+        BackupPasswordManager newManager = new BackupPasswordManager(
+                mContext, mStateFolder, new SecureRandom());
+        assertThat(result).isFalse();
+        assertThat(newManager.backupPasswordMatches(secondPassword)).isFalse();
+    }
+
+    @Test
+    public void setBackupPassword_throwsSecurityExceptionIfLacksPermission() {
+        setDoesNotHavePermission();
+
+        assertExpectException(
+                SecurityException.class,
+                /* expectedExceptionMessageRegex */ null,
+                () -> mPasswordManager.setBackupPassword(
+                        "password123", "password111"));
+    }
+
+    private byte[] saltFixture() {
+        byte[] bytes = new byte[64];
+        for (int i = 0; i < 64; i++) {
+            bytes[i] = (byte) i;
+        }
+        return bytes;
+    }
+
+    private void setDoesNotHavePermission() {
+        doThrow(new SecurityException()).when(mContext)
+                .enforceCallingOrSelfPermission(anyString(), anyString());
+    }
+
+    private void writeV1HashToFile(String password, byte[] salt) throws Exception {
+        String hash = PasswordUtils.buildPasswordHash(
+                V1_HASH_ALGORITHM, password, salt, PasswordUtils.PBKDF2_HASH_ROUNDS);
+        writeHashAndSaltToFile(hash, salt);
+    }
+
+    private void writeHashAndSaltToFile(String hash, byte[] salt) throws Exception {
+        FileOutputStream fos = null;
+        DataOutputStream dos = null;
+
+        try {
+            File passwordHash = new File(mStateFolder, PASSWORD_HASH_FILE_NAME);
+            fos = new FileOutputStream(passwordHash);
+            dos = new DataOutputStream(fos);
+            dos.writeInt(salt.length);
+            dos.write(salt);
+            dos.writeUTF(hash);
+            dos.flush();
+        } finally {
+            if (dos != null) dos.close();
+            if (fos != null) fos.close();
+        }
+    }
+
+    private void writePasswordVersionToFile(int version) throws Exception {
+        FileOutputStream fos = null;
+        DataOutputStream dos = null;
+
+        try {
+            File passwordVersion = new File(mStateFolder, PASSWORD_VERSION_FILE_NAME);
+            fos = new FileOutputStream(passwordVersion);
+            dos = new DataOutputStream(fos);
+            dos.writeInt(version);
+            dos.flush();
+        } finally {
+            if (dos != null) dos.close();
+            if (fos != null) fos.close();
+        }
+    }
+}
diff --git a/services/tests/servicestests/src/com/android/server/backup/utils/DataStreamFileCodecTest.java b/services/tests/servicestests/src/com/android/server/backup/utils/DataStreamFileCodecTest.java
new file mode 100644 (file)
index 0000000..bfb95c1
--- /dev/null
@@ -0,0 +1,86 @@
+/*
+ * Copyright (C) 2017 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License
+ */
+
+package com.android.server.backup.utils;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+
+import java.io.DataInputStream;
+import java.io.DataOutputStream;
+import java.io.File;
+import java.io.IOException;
+
+@SmallTest
+@Presubmit
+@RunWith(AndroidJUnit4.class)
+public final class DataStreamFileCodecTest {
+    @Rule public TemporaryFolder mTemporaryFolder = new TemporaryFolder();
+
+    @Test
+    public void serialize_writesToTheFile() throws Exception {
+        File unicornFile = mTemporaryFolder.newFile();
+
+        DataStreamFileCodec<MythicalCreature> mythicalCreatureCodec = new DataStreamFileCodec<>(
+                unicornFile, new MythicalCreatureDataStreamCodec());
+        MythicalCreature unicorn = new MythicalCreature(
+                10000, "Unicorn");
+        mythicalCreatureCodec.serialize(unicorn);
+
+        DataStreamFileCodec<MythicalCreature> newCodecWithSameFile = new DataStreamFileCodec<>(
+                unicornFile, new MythicalCreatureDataStreamCodec());
+        MythicalCreature deserializedUnicorn = newCodecWithSameFile.deserialize();
+
+        assertThat(deserializedUnicorn.averageLifespanInYears)
+                .isEqualTo(unicorn.averageLifespanInYears);
+        assertThat(deserializedUnicorn.name).isEqualTo(unicorn.name);
+    }
+
+    private static class MythicalCreature {
+        int averageLifespanInYears;
+        String name;
+
+        MythicalCreature(int averageLifespanInYears, String name) {
+            this.averageLifespanInYears = averageLifespanInYears;
+            this.name = name;
+        }
+    }
+
+    private static class MythicalCreatureDataStreamCodec implements
+            DataStreamCodec<MythicalCreature> {
+        @Override
+        public void serialize(MythicalCreature mythicalCreature,
+                DataOutputStream dataOutputStream) throws IOException {
+            dataOutputStream.writeInt(mythicalCreature.averageLifespanInYears);
+            dataOutputStream.writeUTF(mythicalCreature.name);
+        }
+
+        @Override
+        public MythicalCreature deserialize(DataInputStream dataInputStream)
+                throws IOException {
+            int years = dataInputStream.readInt();
+            String name = dataInputStream.readUTF();
+            return new MythicalCreature(years, name);
+        }
+    }
+}