/**
*
*/
package org.commcare.android.database.app.models;
import org.commcare.CommCareApp;
import org.commcare.models.database.SqlStorage;
import org.commcare.models.encryption.ByteEncrypter;
import org.commcare.android.storage.framework.Persisted;
import org.commcare.models.framework.Persisting;
import org.commcare.models.framework.Table;
import org.commcare.modern.models.MetaField;
import org.javarosa.core.util.PropertyUtils;
import java.math.BigInteger;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Date;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* @author ctsims
*/
@Table(UserKeyRecord.STORAGE_KEY)
public class UserKeyRecord extends Persisted {
public static final String STORAGE_KEY = "user_key_records";
public static final String META_USERNAME = "username";
public static final String META_SANDBOX_ID = "sandbox_id";
public static final String META_KEY_STATUS = "status";
/**
* This is a normal sandbox record that is ready to be used *
*/
public static final int TYPE_NORMAL = 1;
/**
* This is a record representing a legacy database that should be transfered over *
*/
public static final int TYPE_LEGACY_TRANSITION = 2;
/**
* This is a new record that hasn't been evaluated for usage yet *
*/
public static final int TYPE_NEW = 3;
/**
* This is a new record that hasn't been evaluated for usage yet *
*/
public static final int TYPE_PENDING_DELETE = 4;
// Hashed passwords should contain 3 groupings that are delimited by '$'.
// The 1st group describes the hashing algorithm, the 2nd is the salt, and
// the 3rd group is the digest.
private static final Pattern HASH_STRING_PATTERN = Pattern.compile("([^\\$]+)\\$([^\\$]+)\\$([^\\$]+)");
private static final int DEFAULT_SALT_LENGTH = 6;
@Persisting(1)
@MetaField(META_USERNAME)
private String username;
@Persisting(2)
private String passwordHash;
@Persisting(3)
private byte[] encryptedKey;
@Persisting(4)
private Date validFrom;
@Persisting(5)
private Date validTo;
/**
* The unique ID of the data sandbox covered by this key
**/
@Persisting(6)
@MetaField(META_SANDBOX_ID)
private String uuid;
@MetaField(META_KEY_STATUS)
@Persisting(7)
private int type;
/**
* The un-hashed password wrapped by a numeric PIN
**/
@Persisting(8)
private byte[] passwordWrappedByPin;
/**
* When a user selects the 'Remember password for next login' option, their un-hashed password
* gets saved here and then used in the next login, so that the user does not need to enter it
*/
@Persisting(9)
private String rememberedPassword;
/**
* If there are multiple UKRs for a single username in app storage, we guarantee that only
* 1 will be marked as active
*/
@Persisting(10)
private boolean isActive;
/**
* Serialization Only!
*/
public UserKeyRecord() {
}
public UserKeyRecord(String username, String passwordHash, byte[] encryptedKey,
Date validFrom, Date validTo, String uuid) {
this(username, passwordHash, encryptedKey, validFrom, validTo, uuid, TYPE_NORMAL);
}
public UserKeyRecord(String username, String passwordHash, byte[] encryptedKey,
Date validFrom, Date validTo, String uuid, int type) {
this(username, passwordHash, encryptedKey, null, validFrom, validTo, uuid, type);
}
public UserKeyRecord(String username, String passwordHash, byte[] encryptedKey,
byte[] wrappedPassword, Date validFrom, Date validTo, String uuid,
int type) {
this.username = username;
this.passwordHash = passwordHash;
this.encryptedKey = encryptedKey;
if (wrappedPassword != null) {
this.passwordWrappedByPin = wrappedPassword;
} else {
// Means no PIN has been assigned yet, so just set a placeholder that is non-null
// (Persisting fields can't be null)
this.passwordWrappedByPin = new byte[0];
}
this.validFrom = validFrom;
this.validTo = validTo;
this.uuid = uuid;
this.type = type;
this.rememberedPassword = "";
// All new UKRs initialized to active
this.isActive = true;
}
public static UserKeyRecord buildFrom(UserKeyRecord referenceRecord, int newType) {
return new UserKeyRecord(
referenceRecord.getUsername(), referenceRecord.getPasswordHash(),
referenceRecord.getEncryptedKey(), referenceRecord.getWrappedPassword(),
referenceRecord.getValidFrom(), referenceRecord.getValidTo(), referenceRecord.getUuid(),
newType);
}
public String getUsername() {
return username;
}
public String getPasswordHash() {
return passwordHash;
}
public byte[] getEncryptedKey() {
return encryptedKey;
}
public Date getValidFrom() {
return validFrom;
}
public Date getValidTo() {
return validTo;
}
public String getUuid() {
return uuid;
}
public int getType() {
return type;
}
public boolean isActive() {
return this.isActive;
}
public void setInactive() {
this.isActive = false;
}
public void setActive() {
this.isActive = true;
}
/**
* Build a SHA-1 password hash out of a password string, where the salt is
* generated to be a globally unique string. The hash is delimited by '$'.
*
* @param pwd is the plain-text password inputted by the user.
* @return SHA-1 hashed password
*/
public static String generatePwdHash(String pwd) {
return generatePwdHash(pwd, PropertyUtils.genGUID(DEFAULT_SALT_LENGTH).toLowerCase());
}
/**
* Grab the salt out of a hashed password.
*
* @param pwdString a String with three groups delimited by '$', the second
* containing the salt
* @return salt String out of a hashed password
*/
private static String extractSalt(String pwdString) {
Matcher m = HASH_STRING_PATTERN.matcher(pwdString);
if (m.matches()) {
// grab the salt segment out of the hashed password
return m.group(2);
}
throw new IllegalArgumentException("Unable to extract salt out of hashed password.");
}
/**
* Build a SHA-1 password hash out of a password string and a salt.
* The hash is delimited by '$'.
*
* @param pwd is the plain-text password inputted by the user.
* @param salt is a random string included during hashing to prevent
* against hash dictionary attacks.
* @return SHA-1 hashed password
*/
private static String generatePwdHash(String pwd, String salt) {
String alg = "sha1";
int hashLength = 41;
MessageDigest md;
try {
md = MessageDigest.getInstance("SHA-1");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
BigInteger number = new BigInteger(1, md.digest((salt + pwd).getBytes()));
String hashed = number.toString(16);
// prepend 0's until the hash is of the correct length
while (hashed.length() < hashLength) {
hashed = "0" + hashed;
}
return alg + "$" + salt + "$" + hashed;
}
public void setType(int typeNormal) {
this.type = typeNormal;
}
public boolean isPasswordValid(String password) {
if (password == null) {
return false;
}
String hash = this.getPasswordHash();
// Is the local hash value a valid hash string pattern
// and does it match the hashed password (using the extracted salt)?
return (HASH_STRING_PATTERN.matcher(hash).matches() &&
hash.equals(UserKeyRecord.generatePwdHash(password, UserKeyRecord.extractSalt(hash))));
}
public void assignPinToRecord(String pin, String password) {
this.passwordWrappedByPin = ByteEncrypter.wrapByteArrayWithString(password.getBytes(), pin);
}
public byte[] getWrappedPassword() {
return passwordWrappedByPin;
}
public boolean hasPinSet() {
return passwordWrappedByPin.length > 0;
}
public boolean isPinValid(String pin) {
// Unwrap wrapped password with the PIN, and then check if the resulting password is correct
return isPasswordValid(getUnhashedPasswordViaPin(pin));
}
/**
* Returns the un-hashed password that was wrapped by the given PIN, or null if the given PIN
* is not valid to unwrap the wrapped password
*/
public String getUnhashedPasswordViaPin(String pin) {
byte[] unwrapped = ByteEncrypter.unwrapByteArrayWithString(this.passwordWrappedByPin, pin);
if (unwrapped == null) {
// If the pin could not unwrap the password, just return null
return null;
}
return new String(unwrapped);
}
/**
* Must be called with 1 of the 2 values set to null, which indicates that we should check
* based upon the other
*/
public boolean isPasswordOrPinValid(String password, String pin) {
if (pin != null) {
return isPinValid(pin);
} else {
return isPasswordValid(password);
}
}
public byte[] unWrapKey(String password) {
if (isPasswordValid(password)) {
return ByteEncrypter.unwrapByteArrayWithString(getEncryptedKey(), password);
} else {
//throw exception?
return null;
}
}
/**
* Does today lie within the record's validity range.
*
* Expiration dates that are null or overflowed are ignored during this
* check.
*/
public boolean isCurrentlyValid() {
// NOTE: we expect our validity dates to be in UTC
// currentTimeMillis is UTC
Date today = new Date(System.currentTimeMillis());
// Does today lie within key record validity range (ignoring
// null/overflowed expiration dates)?
return (validFrom.before(today) &&
(validTo == null ||
(validTo.getTime() != Long.MAX_VALUE && validTo.after(today))));
}
public static UserKeyRecord getCurrentValidRecordByPassword(CommCareApp app, String username,
String pw, boolean acceptExpired) {
return getCurrentValidRecord(app, username, pw, null, acceptExpired);
}
public static UserKeyRecord getCurrentValidRecordByPin(CommCareApp app, String username,
String pin, boolean acceptExpired) {
return getCurrentValidRecord(app, username, null, pin, acceptExpired);
}
public static UserKeyRecord getMatchingPrimedRecord(CommCareApp app, String username) {
SqlStorage<UserKeyRecord> storage = app.getStorage(UserKeyRecord.class);
for (UserKeyRecord ukr : storage.getRecordsForValue(UserKeyRecord.META_USERNAME, username)) {
if (ukr.isPrimedForNextLogin()) {
return ukr;
}
}
return null;
}
/**
* @return The user record that matches the given username/password or username/pin combo.
* Null if not found or user record validity date is expired.
*/
private static UserKeyRecord getCurrentValidRecord(CommCareApp app, String username, String pw,
String pin, boolean acceptExpired) {
UserKeyRecord invalidRecord = null;
SqlStorage<UserKeyRecord> storage = app.getStorage(UserKeyRecord.class);
for (UserKeyRecord ukr : storage.getRecordsForValue(UserKeyRecord.META_USERNAME, username)) {
if (ukr.isPasswordOrPinValid(pw, pin)) {
if (ukr.isCurrentlyValid()) {
return ukr;
} else {
invalidRecord = ukr;
}
}
}
if (acceptExpired) {
return invalidRecord;
}
return null;
}
public void setPrimedPassword(String unhashedPassword) {
this.rememberedPassword = unhashedPassword;
}
public boolean isPrimedForNextLogin() {
return !"".equals(rememberedPassword);
}
public String getPrimedPassword() {
return this.rememberedPassword;
}
public void clearPrimedPassword() {
this.rememberedPassword = "";
}
/**
* Used for app db migration only
*/
public static UserKeyRecord fromOldVersion(UserKeyRecordV1 oldRecord) {
UserKeyRecord newRecord = new UserKeyRecord(
oldRecord.getUsername(),
oldRecord.getPasswordHash(),
oldRecord.getEncryptedKey(),
oldRecord.getValidFrom(),
oldRecord.getValidTo(),
oldRecord.getUuid(),
oldRecord.getType());
// Going to set all of these to inactive to start with, and then the migration code will
// take care of assigning the active flag back to the right ones
newRecord.setInactive();
return newRecord;
}
}