blob: ee7651b0a087eea50274316d40a0b87a3c03bafd [file] [log] [blame]
/*
* 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);
}
}
}