Add U2F support to the connected mode

This commit is contained in:
Jonas Lochmann 2022-09-19 02:00:00 +02:00
parent f804f421ff
commit d8b492e2e3
No known key found for this signature in database
GPG key ID: 8B8C9AEE10FA5B36
26 changed files with 505 additions and 173 deletions

View file

@ -27,7 +27,10 @@ import javax.crypto.KeyAgreement
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
data class DHHandshake(val keyVersion: String, val ownPublicKey: ByteArray, val sharedSecret: ByteArray) {
data class DHHandshake(
val keyVersion: String, val otherPublicKey: ByteArray,
val ownPublicKey: ByteArray, val sharedSecret: ByteArray
) {
companion object {
private const val SHARED_SECRET_LENGTH = 32
private const val AES_KEY_SIZE = 16
@ -51,6 +54,7 @@ data class DHHandshake(val keyVersion: String, val ownPublicKey: ByteArray, val
return DHHandshake(
keyVersion = serverDhKey.version,
otherPublicKey = serverDhKey.key,
ownPublicKey = ownPublicKey,
sharedSecret = sharedSecret
)

View file

@ -379,4 +379,12 @@ abstract class ConfigDao {
updateValueSync(ConfigurationItemType.DhKeyVersion, key.version)
updateValueSync(ConfigurationItemType.DhKey, key.key.base64())
}
fun getU2fVersionSync(): String? {
return getValueOfKeySync(ConfigurationItemType.U2fListVersion)
}
fun setU2fListVersionSync(version: String?) {
updateValueSync(ConfigurationItemType.U2fListVersion, version)
}
}

View file

@ -35,6 +35,9 @@ interface U2FDao {
@Query("SELECT * FROM user_u2f_key WHERE user_id = :userId")
fun getByUserLive(userId: String): LiveData<List<UserU2FKey>>
@Query("SELECT * FROM user_u2f_key WHERE key_id = :keyId")
fun getByClientKeyIdLive(keyId: Long): LiveData<UserU2FKey?>
@Query("UPDATe user_u2f_key SET next_counter = :counter + 1 WHERE user_id = :parentUserId AND key_handle = :keyHandle AND public_key = :publicKey AND :counter >= next_counter")
fun updateCounter(parentUserId: String, keyHandle: ByteArray, publicKey: ByteArray, counter: Long): Int
}

View file

@ -105,7 +105,8 @@ enum class ConfigurationItemType {
LastServerKeyRequestSequence,
LastKeyResponseSequence,
DhKey,
DhKeyVersion
DhKeyVersion,
U2fListVersion
}
object ConfigurationItemTypeUtil {
@ -140,6 +141,7 @@ object ConfigurationItemTypeUtil {
private const val LAST_SERVER_KEY_RESPONSE_SEQUENCE = 30
private const val DH_KEY = 31
private const val DH_KEY_VERSION = 32
private const val U2F_LIST_VERSION = 33
val TYPES = listOf(
ConfigurationItemType.OwnDeviceId,
@ -172,7 +174,8 @@ object ConfigurationItemTypeUtil {
ConfigurationItemType.LastServerKeyRequestSequence,
ConfigurationItemType.LastKeyResponseSequence,
ConfigurationItemType.DhKey,
ConfigurationItemType.DhKeyVersion
ConfigurationItemType.DhKeyVersion,
ConfigurationItemType.U2fListVersion
)
fun serialize(value: ConfigurationItemType) = when(value) {
@ -207,6 +210,7 @@ object ConfigurationItemTypeUtil {
ConfigurationItemType.LastKeyResponseSequence -> LAST_SERVER_KEY_RESPONSE_SEQUENCE
ConfigurationItemType.DhKey -> DH_KEY
ConfigurationItemType.DhKeyVersion -> DH_KEY_VERSION
ConfigurationItemType.U2fListVersion -> U2F_LIST_VERSION
}
fun parse(value: Int) = when(value) {
@ -241,6 +245,7 @@ object ConfigurationItemTypeUtil {
LAST_SERVER_KEY_RESPONSE_SEQUENCE -> ConfigurationItemType.LastKeyResponseSequence
DH_KEY -> ConfigurationItemType.DhKey
DH_KEY_VERSION -> ConfigurationItemType.DhKeyVersion
U2F_LIST_VERSION -> ConfigurationItemType.U2fListVersion
else -> throw IllegalArgumentException()
}
}

View file

@ -16,6 +16,9 @@
package io.timelimit.android.data.model
import androidx.room.*
import io.timelimit.android.extensions.base64
import io.timelimit.android.extensions.toByteArray
import java.security.MessageDigest
@Entity(
tableName = "user_u2f_key",
@ -49,4 +52,14 @@ data class UserU2FKey (
val publicKey: ByteArray,
@ColumnInfo(name = "next_counter")
val nextCounter: Long
)
) {
fun calculateServerKeyIdSync() = MessageDigest.getInstance("SHA256").also {
it.update(keyHandle.size.toByteArray())
it.update(keyHandle)
it.update(publicKey.size.toByteArray())
it.update(publicKey)
}.digest()
.sliceArray(0 until 6)
.base64()
}

View file

@ -0,0 +1,29 @@
/*
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation version 3 of the License.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.extensions
import okio.ByteString.Companion.toByteString
import java.nio.ByteBuffer
fun Int.toByteArray() = ByteBuffer.allocate(Int.SIZE_BYTES).also {
it.putInt(this)
it.rewind()
}.toByteString().toByteArray()
fun Long.toByteArray() = ByteBuffer.allocate(Long.SIZE_BYTES).also {
it.putLong(this)
it.rewind()
}.toByteString().toByteArray()

View file

@ -614,6 +614,42 @@ object ApplyServerDataStatus {
}
}
status.u2f?.also { u2f ->
database.config().setU2fListVersionSync(u2f.version)
val savedKeys = database.u2f().getAllSync().toMutableList()
u2f.data.forEach { newKey ->
val oldKey = savedKeys.find {
it.keyHandle.contentEquals(newKey.keyHandle) &&
it.publicKey.contentEquals(newKey.publicKey)
}
if (oldKey != null) {
savedKeys.remove(oldKey)
} else {
database.u2f().addKey(
UserU2FKey(
keyId = 0,
userId = newKey.userId,
addedAt = newKey.addedAt,
keyHandle = newKey.keyHandle,
publicKey = newKey.publicKey,
nextCounter = 0
)
)
}
}
savedKeys.forEach { key ->
database.u2f().deleteKey(
parentUserId = key.userId,
keyHandle = key.keyHandle,
publicKey = key.publicKey
)
}
}
Result(
newDeviceTitles = newDeviceTitles,
didCreateNewActions = didCreateNewActions

View file

@ -2122,6 +2122,18 @@ data class RemoveParentU2FKey(val publicKey: ByteArray, val keyHandle: ByteArray
}
}
object ReportU2fLoginAction: ParentAction() {
const val TYPE_VALUE = "REPORT_U2F_LOGIN"
override fun serialize(writer: JsonWriter) {
writer.beginObject()
writer.name(TYPE).value(TYPE_VALUE)
writer.endObject()
}
}
// child actions
object ChildSignInAction: ChildAction() {
private const val TYPE_VALUE = "CHILD_SIGN_IN"

View file

@ -25,15 +25,21 @@ import io.timelimit.android.data.Database
import io.timelimit.android.data.model.PendingSyncAction
import io.timelimit.android.data.model.PendingSyncActionType
import io.timelimit.android.data.model.UserType
import io.timelimit.android.extensions.base64
import io.timelimit.android.extensions.toByteArray
import io.timelimit.android.integration.platform.PlatformIntegration
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.ServerApiLevelLogic
import io.timelimit.android.sync.SyncUtil
import io.timelimit.android.sync.actions.*
import io.timelimit.android.sync.actions.dispatch.LocalDatabaseAppLogicActionDispatcher
import io.timelimit.android.sync.actions.dispatch.LocalDatabaseChildActionDispatcher
import io.timelimit.android.sync.actions.dispatch.LocalDatabaseParentActionDispatcher
import io.timelimit.android.ui.main.AuthenticatedUser
import org.json.JSONObject
import java.io.StringWriter
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
object ApplyActionUtil {
private const val LOG_TAG = "ApplyActionUtil"
@ -192,7 +198,13 @@ object ApplyActionUtil {
}
}
suspend fun applyParentAction(action: ParentAction, database: Database, authentication: ApplyActionParentAuthentication, syncUtil: SyncUtil, platformIntegration: PlatformIntegration) {
suspend fun applyParentAction(
action: ParentAction,
database: Database,
authentication: ApplyActionParentAuthentication,
syncUtil: SyncUtil,
platformIntegration: PlatformIntegration
) {
Threads.database.executeAndWait {
database.runInTransaction {
val deviceUserIdBeforeDispatchingForDeviceAuth = if (authentication is ApplyActionParentDeviceAuthentication || authentication is ApplyActionChildAddLimitAuthentication) {
@ -226,7 +238,7 @@ object ApplyActionUtil {
database = database,
fromChildSelfLimitAddChildUserId = if (authentication is ApplyActionChildAddLimitAuthentication) deviceUserIdBeforeDispatchingForDeviceAuth else null,
parentUserId = when (authentication) {
is ApplyActionParentPasswordAuthentication -> authentication.parentUserId
is ApplyActionUserAuthentication -> authentication.user.userId
is ApplyActionParentDeviceAuthentication -> deviceUserIdBeforeDispatchingForDeviceAuth
is ApplyActionChildAddLimitAuthentication -> null
}
@ -249,27 +261,62 @@ object ApplyActionUtil {
val sequenceNumber = database.config().getNextSyncActionSequenceActionAndIncrementIt()
fun mac(secret: ByteArray) = Mac.getInstance("HmacSHA256").also {
val binaryDeviceId = database.config().getOwnDeviceIdSync()!!.toByteArray(Charsets.UTF_8)
val binaryAction = serializedAction.toByteArray(Charsets.UTF_8)
it.init(SecretKeySpec(secret, "HmacSHA256"))
it.update(sequenceNumber.toByteArray())
it.update(binaryDeviceId.size.toByteArray())
it.update(binaryDeviceId)
it.update(binaryAction.size.toByteArray())
it.update(binaryAction)
}.doFinal()
val pendingAction = when (authentication) {
is ApplyActionParentPasswordAuthentication -> {
val integrityData = sequenceNumber.toString() +
database.config().getOwnDeviceIdSync() +
authentication.secondPasswordHash +
serializedAction
is ApplyActionUserAuthentication -> {
val integrity = when (authentication.user) {
is AuthenticatedUser.Password -> {
val serverLevel = ServerApiLevelLogic.getSync(database)
val hashedIntegrityData = Sha512.hashSync(integrityData)
if (serverLevel.hasLevelOrIsOffline(6)) {
val mac = mac(authentication.user.secondPasswordHash.toByteArray(Charsets.UTF_8))
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "integrity data: $integrityData")
Log.d(LOG_TAG, "integrity hash: $hashedIntegrityData")
"password:${mac.base64()}"
} else {
val integrityData = sequenceNumber.toString() +
database.config().getOwnDeviceIdSync() +
authentication.user.secondPasswordHash +
serializedAction
val hashedIntegrityData = Sha512.hashSync(integrityData)
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "integrity data: $integrityData")
Log.d(LOG_TAG, "integrity hash: $hashedIntegrityData")
}
hashedIntegrityData
}
}
is AuthenticatedUser.U2fSigned -> {
val mac = mac(authentication.user.dh.sharedSecret)
constructU2fIntegrityString(authentication.user, mac)
}
is AuthenticatedUser.LocalAuth -> throw IllegalStateException()
}
PendingSyncAction(
sequenceNumber = sequenceNumber,
encodedAction = serializedAction,
integrity = hashedIntegrityData,
scheduledForUpload = false,
type = PendingSyncActionType.Parent,
userId = authentication.parentUserId
sequenceNumber = sequenceNumber,
encodedAction = serializedAction,
integrity = integrity,
scheduledForUpload = false,
type = PendingSyncActionType.Parent,
userId = authentication.user.userId
)
}
ApplyActionParentDeviceAuthentication -> {
@ -353,6 +400,10 @@ object ApplyActionUtil {
}
}
fun constructU2fIntegrityString(authentication: AuthenticatedUser.U2fSigned, mac: ByteArray): String {
return "u2f:${authentication.dh.keyVersion}.${authentication.dh.ownPublicKey.base64()}.${authentication.u2fServerKeyId}.${authentication.signature.raw.base64()}.${mac.base64()}"
}
private fun isSyncEnabled(database: Database): Boolean {
return database.config().getDeviceAuthTokenSync() != ""
}
@ -361,6 +412,34 @@ object ApplyActionUtil {
sealed class ApplyActionParentAuthentication
object ApplyActionParentDeviceAuthentication: ApplyActionParentAuthentication()
object ApplyActionChildAddLimitAuthentication: ApplyActionParentAuthentication()
data class ApplyActionParentPasswordAuthentication(val parentUserId: String, val secondPasswordHash: String): ApplyActionParentAuthentication()
data class ApplyActionUserAuthentication(val user: AuthenticatedUser): ApplyActionParentAuthentication()
data class ApplyActionChildAuthentication(val childUserId: String, val secondPasswordHash: String)
data class ApplyActionChildAuthentication(val childUserId: String, val secondPasswordHash: String)
data class ApplyDirectCallAuthentication (
val parentUserId: String,
val parentPasswordSecondHash: String
) {
companion object {
fun from(auth: ApplyActionParentAuthentication) = when (auth) {
ApplyActionParentDeviceAuthentication -> ApplyDirectCallAuthentication(
parentUserId = "",
parentPasswordSecondHash = "device"
)
is ApplyActionUserAuthentication -> ApplyDirectCallAuthentication(
parentUserId = auth.user.userId,
parentPasswordSecondHash = when (auth.user) {
is AuthenticatedUser.Password -> auth.user.secondPasswordHash
is AuthenticatedUser.U2fSigned -> ApplyActionUtil.constructU2fIntegrityString(
auth.user,
Mac.getInstance("HmacSHA256").also {
it.init(SecretKeySpec(auth.user.dh.sharedSecret, "HmacSHA256"))
it.update("direct action".toByteArray(Charsets.UTF_8))
}.doFinal()
)
is AuthenticatedUser.LocalAuth -> throw RuntimeException("authentication does not support that")
}
)
is ApplyActionChildAddLimitAuthentication -> throw RuntimeException("child can not do that")
}
}
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -30,6 +30,7 @@ class UploadActionsUtil(private val database: Database, private val syncConflict
database.runInTransaction {
database.config().setUserListVersionSync("")
database.config().setDeviceListVersionSync("")
database.config().setU2fListVersionSync(null)
database.device().deleteAllInstalledAppsVersions()
database.category().deleteAllCategoriesVersionNumbers()
database.cryptContainer().deleteAllServerVersionNumbers()

View file

@ -922,6 +922,7 @@ object LocalDatabaseParentActionDispatcher {
publicKey = action.publicKey
)
}
is ReportU2fLoginAction -> null // nothing to do
}.let { }
}
}

View file

@ -26,7 +26,8 @@ data class ClientDataStatus(
val userListVersion: String,
val lastKeyRequestServerSequence: Long?,
val lastKeyResponseServerSequence: Long?,
val dhKeyVersion: String?
val dhKeyVersion: String?,
val u2fVersion: String?
) {
companion object {
private const val DEVICES = "devices"
@ -38,7 +39,8 @@ data class ClientDataStatus(
private const val LAST_KEY_REQUEST_SEQUENCE = "kri"
private const val LAST_KEY_RESPONSE_SEQUENCE = "kr"
private const val DH = "dh"
private const val CLIENT_LEVEL_VALUE = 5
private const val U2F = "u2f"
private const val CLIENT_LEVEL_VALUE = 6
val empty = ClientDataStatus(
deviceListVersion = "",
@ -48,7 +50,8 @@ data class ClientDataStatus(
userListVersion = "",
lastKeyRequestServerSequence = null,
lastKeyResponseServerSequence = null,
dhKeyVersion = null
dhKeyVersion = null,
u2fVersion = null
)
fun getClientDataStatusSync(database: Database): ClientDataStatus {
@ -87,7 +90,8 @@ data class ClientDataStatus(
userListVersion = database.config().getUserListVersionSync(),
lastKeyRequestServerSequence = database.config().getLastServerKeyRequestSequenceSync(),
lastKeyResponseServerSequence = database.config().getLastServerKeyResponseSequenceSync(),
dhKeyVersion = database.config().getLastDhKeySync()?.version
dhKeyVersion = database.config().getLastDhKeySync()?.version,
u2fVersion = database.config().getU2fVersionSync()
)
}
}
@ -128,6 +132,7 @@ data class ClientDataStatus(
lastKeyRequestServerSequence?.let { writer.name(LAST_KEY_REQUEST_SEQUENCE).value(it) }
lastKeyResponseServerSequence?.let { writer.name(LAST_KEY_RESPONSE_SEQUENCE).value(it) }
dhKeyVersion?.let { writer.name(DH).value(it) }
u2fVersion?.let { writer.name(U2F).value(it) }
writer.endObject()
}

View file

@ -47,6 +47,7 @@ data class ServerDataStatus(
val pendingKeyRequests: List<ServerKeyRequest>,
val keyResponses: List<ServerKeyResponse>,
val dh: ServerDhKey?,
val u2f: ServerU2fData?,
val fullVersionUntil: Long,
val message: String?,
val apiLevel: Int
@ -65,6 +66,7 @@ data class ServerDataStatus(
private const val PENDING_KEY_REQUESTS = "krq"
private const val KEY_RESPONSES = "kr"
private const val DH = "dh"
private const val U2F = "u2f"
private const val FULL_VERSION_UNTIL = "fullVersion"
private const val MESSAGE = "message"
private const val API_LEVEL = "apiLevel"
@ -83,6 +85,7 @@ data class ServerDataStatus(
var pendingKeyRequests = emptyList<ServerKeyRequest>()
var keyResponses = emptyList<ServerKeyResponse>()
var dh: ServerDhKey? = null
var u2f: ServerU2fData? = null
var fullVersionUntil: Long? = null
var message: String? = null
var apiLevel = 0
@ -103,6 +106,7 @@ data class ServerDataStatus(
PENDING_KEY_REQUESTS -> pendingKeyRequests = ServerKeyRequest.parseList(reader)
KEY_RESPONSES -> keyResponses = ServerKeyResponse.parseList(reader)
DH -> dh = ServerDhKey.parse(reader)
U2F -> u2f = ServerU2fData.parse(reader)
FULL_VERSION_UNTIL -> fullVersionUntil = reader.nextLong()
MESSAGE -> message = reader.nextString()
API_LEVEL -> apiLevel = reader.nextInt()
@ -125,6 +129,7 @@ data class ServerDataStatus(
pendingKeyRequests = pendingKeyRequests,
keyResponses = keyResponses,
dh = dh,
u2f = u2f,
fullVersionUntil = fullVersionUntil!!,
message = message,
apiLevel = apiLevel
@ -1296,4 +1301,76 @@ data class ServerDhKey(val version: String, val key: ByteArray) {
)
}
}
}
data class ServerU2fData(
val version: String,
val data: List<ServerU2fItem>
) {
companion object {
private const val VERSION = "v"
private const val DATA = "d"
fun parse(reader: JsonReader): ServerU2fData {
var version: String? = null
var data: List<ServerU2fItem>? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
VERSION -> version = reader.nextString()
DATA -> data = ServerU2fItem.parseList(reader)
else -> reader.skipValue()
}
}
reader.endObject()
return ServerU2fData(
version = version!!,
data = data!!
)
}
}
}
data class ServerU2fItem(
val userId: String,
val addedAt: Long,
val keyHandle: ByteArray,
val publicKey: ByteArray
) {
companion object {
private const val USER_ID = "u"
private const val ADDED_AT = "a"
private const val KEY_HANDLE = "h"
private const val PUBLIC_KEY = "p"
fun parseList(reader: JsonReader) = parseJsonArray(reader) { parse(reader) }
fun parse(reader: JsonReader): ServerU2fItem {
var userId: String? = null
var addedAt: Long? = null
var keyHandle: ByteArray? = null
var publicKey: ByteArray? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
USER_ID -> userId = reader.nextString()
ADDED_AT -> addedAt = reader.nextLong()
KEY_HANDLE -> keyHandle = reader.nextString().parseBase64()
PUBLIC_KEY -> publicKey = reader.nextString().parseBase64()
else -> reader.skipValue()
}
}
reader.endObject()
return ServerU2fItem(
userId = userId!!,
addedAt = addedAt!!,
keyHandle = keyHandle!!,
publicKey = publicKey!!
)
}
}
}

View file

@ -48,6 +48,16 @@ object U2FResponse {
val counter: UInt,
val signature: ByteArray
) {
val raw by lazy {
byteArrayOf(
flags,
counter.shr(24).toUByte().toByte(),
counter.shr(16).toUByte().toByte(),
counter.shr(8).toUByte().toByte(),
counter.toUByte().toByte(),
) + signature
}
companion object {
fun parse(rawResponse: U2fRawResponse): Login {
if (rawResponse.payload.size < 5) throw U2FException.InvalidDataException()

View file

@ -22,6 +22,10 @@ import io.timelimit.android.R
import io.timelimit.android.async.Threads
import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.crypto.DHHandshake
import io.timelimit.android.extensions.toByteArray
import io.timelimit.android.livedata.waitForNonNullValue
import io.timelimit.android.sync.actions.ReportU2fLoginAction
import io.timelimit.android.u2f.U2FApplicationId
import io.timelimit.android.u2f.U2FSignatureValidation
import io.timelimit.android.u2f.protocol.U2FDevice
@ -31,7 +35,7 @@ import io.timelimit.android.u2f.util.U2FException
import io.timelimit.android.u2f.util.U2FThread
import io.timelimit.android.ui.main.ActivityViewModel
import io.timelimit.android.ui.main.AuthenticatedUser
import io.timelimit.android.ui.main.AuthenticationMethod
import java.security.MessageDigest
import java.security.SecureRandom
object AuthTokenLoginProcessor {
@ -45,18 +49,52 @@ object AuthTokenLoginProcessor {
runAsync {
try {
val hasFullVersion = model.logic.fullVersion.shouldProvideFullVersionFunctions.waitForNonNullValue()
if (!hasFullVersion) {
toast(R.string.update_primary_device_toast_requires_full_version)
return@runAsync
}
device.connect().use { session ->
val keys = Threads.database.executeAndWait { model.logic.database.u2f().getAllSync() }
val random = SecureRandom()
val (keys, serverDhKey) = Threads.database.executeAndWait {
model.logic.database.runInTransaction {
val u2f = model.logic.database.u2f().getAllSync()
val dh = model.logic.database.config().getLastDhKeySync()
u2f to dh
}
}
val dhHandshake = serverDhKey?.let {
Threads.crypto.executeAndWait {
DHHandshake.fromServerKey(it)
}
}
val applicationId = U2FApplicationId.fromUrl(U2FApplicationId.URL)
val challenge = if (dhHandshake == null) ByteArray(32).also { SecureRandom().nextBytes(it) }
else {
val binaryDhKey = dhHandshake.keyVersion.toByteArray(Charsets.UTF_8)
MessageDigest.getInstance("SHA256").also {
it.update(binaryDhKey.size.toByteArray())
it.update(binaryDhKey)
it.update(dhHandshake.otherPublicKey.size.toByteArray())
it.update(dhHandshake.otherPublicKey)
it.update(dhHandshake.ownPublicKey.size.toByteArray())
it.update(dhHandshake.ownPublicKey)
}.digest()
}
for (key in keys) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "try key $key")
}
val challenge = ByteArray(32).also { random.nextBytes(it) }
try {
val response = session.login(
U2FRequest.Login(
@ -121,13 +159,28 @@ object AuthTokenLoginProcessor {
return@runAsync
}
model.setAuthenticatedUser(
AuthenticatedUser(
val authenticatedUser = if (dhHandshake == null) {
AuthenticatedUser.LocalAuth.U2f(userId = key.userId)
} else {
val serverKeyId = Threads.crypto.executeAndWait {
key.calculateServerKeyIdSync()
}
AuthenticatedUser.U2fSigned(
userId = key.userId,
authenticatedBy = AuthenticationMethod.KeyCode,
firstPasswordHash = userEntry.password,
secondPasswordHash = "u2f"
u2fServerKeyId = serverKeyId,
u2fClientKeyId = key.keyId,
signature = response,
dh = dhHandshake
)
}
model.setAuthenticatedUser(authenticatedUser)
ActivityViewModel.dispatchWithoutCheckOrCatching(
action = ReportU2fLoginAction,
authenticatedUser = authenticatedUser,
logic = model.logic
)
return@runAsync // no need to try more

View file

@ -39,7 +39,6 @@ import io.timelimit.android.sync.actions.apply.ApplyActionChildAuthentication
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import io.timelimit.android.ui.main.ActivityViewModel
import io.timelimit.android.ui.main.AuthenticatedUser
import io.timelimit.android.ui.main.AuthenticationMethod
import io.timelimit.android.ui.manage.parent.key.ScannedKey
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -210,11 +209,10 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli
}
if (shouldSignIn) {
model.setAuthenticatedUser(AuthenticatedUser(
model.setAuthenticatedUser(AuthenticatedUser.Password(
userId = user.id,
firstPasswordHash = user.password,
secondPasswordHash = Threads.crypto.executeAndWait { PasswordHashing.hashSyncWithSalt("", user.secondPasswordSalt) },
authenticatedBy = AuthenticationMethod.Password
secondPasswordHash = Threads.crypto.executeAndWait { PasswordHashing.hashSyncWithSalt("", user.secondPasswordSalt) }
))
isLoginDone.value = true
@ -278,11 +276,8 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli
if (shouldSignIn) {
// this feature is limited to the local mode
model.setAuthenticatedUser(AuthenticatedUser(
userId = user.id,
firstPasswordHash = user.password,
secondPasswordHash = "device",
authenticatedBy = AuthenticationMethod.Password
model.setAuthenticatedUser(AuthenticatedUser.LocalAuth.ScanCode(
userId = user.id
))
isLoginDone.value = true
@ -326,11 +321,10 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli
val secondPasswordHash = Threads.crypto.executeAndWait { PasswordHashing.hashSyncWithSalt(password, userEntry.secondPasswordSalt) }
val authenticatedUser = AuthenticatedUser(
val authenticatedUser = AuthenticatedUser.Password(
userId = userEntry.id,
firstPasswordHash = userEntry.password,
secondPasswordHash = secondPasswordHash,
authenticatedBy = AuthenticationMethod.Password
secondPasswordHash = secondPasswordHash
)
val allowLoginStatus = Threads.database.executeAndWait {

View file

@ -21,19 +21,19 @@ import android.widget.Toast
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.map
import io.timelimit.android.BuildConfig
import io.timelimit.android.R
import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.crypto.DHHandshake
import io.timelimit.android.data.model.User
import io.timelimit.android.data.model.UserType
import io.timelimit.android.livedata.ignoreUnchanged
import io.timelimit.android.livedata.liveDataFromNullableValue
import io.timelimit.android.livedata.map
import io.timelimit.android.livedata.switchMap
import io.timelimit.android.livedata.*
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.sync.actions.ParentAction
import io.timelimit.android.sync.actions.apply.*
import io.timelimit.android.u2f.protocol.U2FResponse
class ActivityViewModel(application: Application): AndroidViewModel(application) {
companion object {
@ -47,10 +47,7 @@ class ActivityViewModel(application: Application): AndroidViewModel(application)
ApplyActionUtil.applyParentAction(
action = action,
database = logic.database,
authentication = ApplyActionParentPasswordAuthentication(
parentUserId = authenticatedUser.userId,
secondPasswordHash = authenticatedUser.secondPasswordHash
),
authentication = ApplyActionUserAuthentication(authenticatedUser),
syncUtil = logic.syncUtil,
platformIntegration = logic.platformIntegration
)
@ -79,41 +76,45 @@ class ActivityViewModel(application: Application): AndroidViewModel(application)
}
}.ignoreUnchanged()
private val authenticatedChild: LiveData<Pair<ApplyActionParentAuthentication, User>?> = deviceUser.map { user ->
private val authenticatedUserChecked = authenticatedUserMetadata.switchMap { user ->
val userEntryLive =
if (user == null) liveDataFromNullableValue(null)
else database.user().getUserByIdLive(user.userId)
userEntryLive.switchMap { userEntry ->
if (userEntry == null) liveDataFromNullableValue(null)
else {
val stillValid = when (user) {
is AuthenticatedUser.Password -> liveDataFromNonNullValue( user.firstPasswordHash == userEntry.password)
is AuthenticatedUser.U2fSigned -> logic.database.u2f().getByClientKeyIdLive(user.u2fClientKeyId).map { keyEntry ->
keyEntry != null && keyEntry.userId == user.userId
}
is AuthenticatedUser.LocalAuth -> logic.fullVersion.isLocalMode
null -> liveDataFromNonNullValue(false)
}
stillValid.map { valid ->
if (valid && user != null) ApplyActionUserAuthentication(user) to userEntry
else null
}
}
}
}
private val authenticatedChildChecked: LiveData<Pair<ApplyActionParentAuthentication, User>?> = deviceUser.map { user ->
if (user?.type == UserType.Child && user.allowSelfLimitAdding) {
ApplyActionChildAddLimitAuthentication as ApplyActionParentAuthentication to user
} else null
}
val authenticatedUserOrChild: LiveData<Pair<ApplyActionParentAuthentication, User>?> = userWhichIsKeptSignedIn.switchMap { signedInUser ->
if (signedInUser != null) {
liveDataFromNullableValue(
(ApplyActionParentDeviceAuthentication to signedInUser)
as Pair<ApplyActionParentAuthentication, User>?
)
} else {
authenticatedUserMetadata.switchMap {
authenticatedUser ->
if (authenticatedUser == null) {
authenticatedChild
} else {
database.user().getUserByIdLive(authenticatedUser.userId).switchMap {
if (it == null || it.password != authenticatedUser.firstPasswordHash) {
authenticatedChild
} else {
liveDataFromNullableValue(
(ApplyActionParentPasswordAuthentication(
parentUserId = authenticatedUser.userId,
secondPasswordHash = authenticatedUser.secondPasswordHash
) to it) as Pair<ApplyActionParentAuthentication, User>?
)
}
}
}
val authenticatedUserOrChild: LiveData<Pair<ApplyActionParentAuthentication, User>?> =
mergeLiveDataWaitForValues(userWhichIsKeptSignedIn, authenticatedChildChecked, authenticatedUserChecked)
.map { (keptSignedIn, localChild, authenticatedUser) ->
keptSignedIn?.let {
ApplyActionParentDeviceAuthentication to it
} ?: authenticatedUser ?: localChild
}
}
}
.ignoreUnchanged()
val authenticatedUser = authenticatedUserOrChild.map { if (it?.second?.type != UserType.Parent) null else it }
@ -198,13 +199,25 @@ class ActivityViewModel(application: Application): AndroidViewModel(application)
}
}
data class AuthenticatedUser (
val userId: String,
val firstPasswordHash: String,
val secondPasswordHash: String,
val authenticatedBy: AuthenticationMethod
)
sealed class AuthenticatedUser {
abstract val userId: String
enum class AuthenticationMethod {
Password, KeyCode
data class Password(
override val userId: String,
val firstPasswordHash: String,
val secondPasswordHash: String
): AuthenticatedUser()
data class U2fSigned(
override val userId: String,
val u2fServerKeyId: String,
val u2fClientKeyId: Long,
val signature: U2FResponse.Login,
val dh: DHHandshake
): AuthenticatedUser()
sealed class LocalAuth: AuthenticatedUser() {
data class U2f(override val userId: String): LocalAuth()
data class ScanCode(override val userId: String): LocalAuth()
}
}

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -23,9 +23,7 @@ import io.timelimit.android.livedata.castDown
import io.timelimit.android.livedata.waitForNonNullValue
import io.timelimit.android.livedata.waitUntilValueMatches
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.sync.actions.apply.ApplyActionChildAddLimitAuthentication
import io.timelimit.android.sync.actions.apply.ApplyActionParentDeviceAuthentication
import io.timelimit.android.sync.actions.apply.ApplyActionParentPasswordAuthentication
import io.timelimit.android.sync.actions.apply.ApplyDirectCallAuthentication
import io.timelimit.android.ui.main.ActivityViewModel
import kotlinx.coroutines.Job
import kotlinx.coroutines.withContext
@ -65,21 +63,13 @@ class AddDeviceModel(application: Application): AndroidViewModel(application) {
statusInternal.value = Failed
} else {
try {
val auth = user.first
val auth = ApplyDirectCallAuthentication.from(user.first)
val response = when (auth) {
ApplyActionParentDeviceAuthentication -> server.api.createAddDeviceToken(
deviceAuthToken = server.deviceAuthToken,
parentUserId = "",
parentPasswordSecondHash = "device"
)
is ApplyActionParentPasswordAuthentication -> server.api.createAddDeviceToken(
deviceAuthToken = server.deviceAuthToken,
parentUserId = auth.parentUserId,
parentPasswordSecondHash = auth.secondPasswordHash
)
is ApplyActionChildAddLimitAuthentication -> throw RuntimeException("child can not do that")
}
val response = server.api.createAddDeviceToken(
deviceAuthToken = server.deviceAuthToken,
parentUserId = auth.parentUserId,
parentPasswordSecondHash = auth.parentPasswordSecondHash
)
statusInternal.value = ShowingToken(response.token)

View file

@ -25,9 +25,7 @@ import io.timelimit.android.R
import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.livedata.castDown
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.sync.actions.apply.ApplyActionChildAddLimitAuthentication
import io.timelimit.android.sync.actions.apply.ApplyActionParentDeviceAuthentication
import io.timelimit.android.sync.actions.apply.ApplyActionParentPasswordAuthentication
import io.timelimit.android.sync.actions.apply.ApplyDirectCallAuthentication
import io.timelimit.android.ui.main.ActivityViewModel
class RemoveDeviceModel(application: Application): AndroidViewModel(application) {
@ -54,28 +52,18 @@ class RemoveDeviceModel(application: Application): AndroidViewModel(application)
if (!server.hasAuthToken) {
Toast.makeText(getApplication(), R.string.remove_device_local_mode, Toast.LENGTH_LONG).show()
} else {
val parent = activityViewModel.authenticatedUser.value?.first
val auth = activityViewModel.authenticatedUser.value?.first?.let {
ApplyDirectCallAuthentication.from(it)
}
if (parent != null) {
if (auth != null) {
try {
when (parent) {
ApplyActionParentDeviceAuthentication -> server.api.removeDevice(
deviceAuthToken = server.deviceAuthToken,
parentUserId = "",
parentPasswordSecondHash = "device",
deviceId = deviceId
)
is ApplyActionParentPasswordAuthentication -> server.api.removeDevice(
deviceAuthToken = server.deviceAuthToken,
parentUserId = parent.parentUserId,
parentPasswordSecondHash = parent.secondPasswordHash,
deviceId = deviceId
)
is ApplyActionChildAddLimitAuthentication -> {
// caught below
throw IllegalStateException()
}
}
server.api.removeDevice(
deviceAuthToken = server.deviceAuthToken,
parentUserId = auth.parentUserId,
parentPasswordSecondHash = auth.parentPasswordSecondHash,
deviceId = deviceId
)
} catch (ex: Exception) {
if (BuildConfig.DEBUG) {
Log.w(LOG_TAG, "removing device failed", ex)

View file

@ -1,5 +1,5 @@
/*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann
* TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
@ -95,6 +95,11 @@ class ManageParentFragment : Fragment(), FragmentWithCustomTitle {
binding.isUsingLocalMode = isLocalMode
})
logic.serverApiLevelLogic.infoLive
.map { it.hasLevelOrIsOffline(6) }
.observe(viewLifecycleOwner)
{ binding.hasU2fSupport = it }
DeleteParentView.bind(
view = binding.deleteParent,
lifecycleOwner = this,

View file

@ -29,9 +29,10 @@ import io.timelimit.android.livedata.castDown
import io.timelimit.android.livedata.waitForNullableValue
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.sync.actions.ChangeParentPasswordAction
import io.timelimit.android.sync.actions.apply.ApplyActionParentPasswordAuthentication
import io.timelimit.android.sync.actions.apply.ApplyActionUserAuthentication
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import io.timelimit.android.sync.network.ParentPassword
import io.timelimit.android.ui.main.AuthenticatedUser
import java.nio.charset.Charset
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
@ -155,14 +156,17 @@ class ChangeParentPasswordViewModel(application: Application): AndroidViewModel(
}
ApplyActionUtil.applyParentAction(
action,
logic.database,
ApplyActionParentPasswordAuthentication(
parentUserId = parentUserId,
secondPasswordHash = oldPasswordSecondHash
),
logic.syncUtil,
logic.platformIntegration
action,
logic.database,
ApplyActionUserAuthentication(
AuthenticatedUser.Password(
userId = parentUserId,
firstPasswordHash = userEntry.password,
secondPasswordHash = oldPasswordSecondHash
)
),
logic.syncUtil,
logic.platformIntegration
)
if (BuildConfig.DEBUG) {

View file

@ -77,14 +77,17 @@ class ManageParentU2FKeyFragment : Fragment(), FragmentWithCustomTitle {
override fun onRemoveClicked(keyItem: U2FKeyListItem.KeyItem) {
if (isAuthValidOrShowMessage()) {
if (activityModel.getAuthenticatedUser()?.authenticatedBy == AuthenticationMethod.Password) {
if (
activityModel.getAuthenticatedUser() is AuthenticatedUser.U2fSigned ||
activityModel.getAuthenticatedUser() is AuthenticatedUser.LocalAuth.U2f
) {
U2FRequiresPasswordForRemovalDialogFragment.newInstance().show(parentFragmentManager)
} else {
RemoveU2FKeyDialogFragment.newInstance(
userId = params.userId,
keyHandle = keyItem.item.keyHandle,
publicKey = keyItem.item.publicKey
).show(parentFragmentManager)
} else {
U2FRequiresPasswordForRemovalDialogFragment.newInstance().show(parentFragmentManager)
}
}
}

View file

@ -25,9 +25,7 @@ import io.timelimit.android.extensions.BillingNotSupportedException
import io.timelimit.android.livedata.castDown
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.sync.actions.apply.ApplyActionChildAddLimitAuthentication
import io.timelimit.android.sync.actions.apply.ApplyActionParentDeviceAuthentication
import io.timelimit.android.sync.actions.apply.ApplyActionParentPasswordAuthentication
import io.timelimit.android.sync.actions.apply.ApplyDirectCallAuthentication
import io.timelimit.android.sync.network.CanDoPurchaseStatus
import io.timelimit.android.sync.network.api.NotFoundHttpError
import io.timelimit.android.ui.main.ActivityViewModel
@ -78,25 +76,16 @@ class PurchaseModel(application: Application): AndroidViewModel(application) {
if (!BuildConfig.storeCompilant) {
if (auth.isParentAuthenticated()) {
val authData = auth.authenticatedUser.value?.first
try {
val token = when (authData) {
ApplyActionParentDeviceAuthentication -> server.api.createIdentityToken(
deviceAuthToken = server.deviceAuthToken,
parentUserId = "",
parentPasswordSecondHash = "device"
)
is ApplyActionParentPasswordAuthentication -> server.api.createIdentityToken(
deviceAuthToken = server.deviceAuthToken,
parentUserId = authData.parentUserId,
parentPasswordSecondHash = authData.secondPasswordHash
)
is ApplyActionChildAddLimitAuthentication -> throw RuntimeException(
"child can not do that"
)
null -> throw RuntimeException("missing user")
}
val authData = ApplyDirectCallAuthentication.from(
auth.authenticatedUser.value?.first!!
)
val token = server.api.createIdentityToken(
deviceAuthToken = server.deviceAuthToken,
parentUserId = authData.parentUserId,
parentPasswordSecondHash = authData.parentPasswordSecondHash
)
statusInternal.value = Status.ReadyToken(token)
} catch (ex: NotFoundHttpError) {

View file

@ -30,6 +30,10 @@
name="isUsingLocalMode"
type="Boolean" />
<variable
name="hasU2fSupport"
type="boolean" />
<variable
name="handlers"
type="io.timelimit.android.ui.manage.parent.ManageParentFragmentHandlers" />
@ -158,7 +162,7 @@
layout="@layout/manage_parent_notifications" />
<androidx.cardview.widget.CardView
android:visibility="@{isUsingLocalMode ? View.VISIBLE : View.GONE}"
android:visibility="@{hasU2fSupport ? View.VISIBLE : View.GONE}"
android:onClick="@{() -> handlers.onManageU2FClicked()}"
android:foreground="?selectableItemBackground"
app:cardUseCompatPadding="true"
@ -182,6 +186,12 @@
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:textAppearance="?android:textAppearanceSmall"
android:text="@string/purchase_required_info_local_mode_free"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
</androidx.cardview.widget.CardView>

View file

@ -1688,7 +1688,7 @@
<string name="manage_parent_u2f_status_done">Das Gerät wurde verknüpft</string>
<string name="manage_parent_u2f_remove_key_text">Möchten sich diesen Schlüssel wirklich entfernen?</string>
<string name="manage_parent_u2f_remove_key_requires_password">Schlüssel können nur nach einer Anmeldung mittels Passwort entfernt werden</string>
<string name="manage_parent_u2f_remove_key_requires_password">Schlüssel können nur nach einer Anmeldung ohne Schlüssel entfernt werden</string>
<string name="manage_parent_u2f_err_wrong_user">Nur der Benutzer selbst kann seine U2F-Schlüssel verwalten</string>

View file

@ -1735,7 +1735,7 @@
<string name="manage_parent_u2f_status_done">The Device was linked</string>
<string name="manage_parent_u2f_remove_key_text">Would you like to remove this key?</string>
<string name="manage_parent_u2f_remove_key_requires_password">You can only remove keys after signing in by password</string>
<string name="manage_parent_u2f_remove_key_requires_password">You can only remove keys after signing in without a key</string>
<string name="manage_parent_u2f_err_wrong_user">Only the user itself can manage its U2F Keys</string>