diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/Util.kt b/android/app/src/main/kotlin/com/yubico/authenticator/Util.kt new file mode 100644 index 00000000..7db04149 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/Util.kt @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2022-2023 Yubico. + * + * 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.yubico.authenticator + +fun ByteArray.asString() = joinToString( + separator = "" +) { b -> "%02x".format(b) } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/Conversion.kt b/android/app/src/main/kotlin/com/yubico/authenticator/oath/Conversion.kt deleted file mode 100644 index b3b8c732..00000000 --- a/android/app/src/main/kotlin/com/yubico/authenticator/oath/Conversion.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright (C) 2022 Yubico. - * - * 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.yubico.authenticator.oath - -import com.yubico.authenticator.device.Version -import com.yubico.yubikit.oath.Code -import com.yubico.yubikit.oath.Credential -import com.yubico.yubikit.oath.OathSession -import com.yubico.yubikit.oath.OathType - -fun ByteArray.asString() = joinToString( - separator = "" -) { b -> "%02x".format(b) } - -// convert yubikit types to Model types -fun OathSession.model(isRemembered: Boolean) = Model.Session( - deviceId, - Version( - version.major, - version.minor, - version.micro - ), - isAccessKeySet, - isRemembered, - isLocked -) - -fun Credential.model(deviceId: String) = Model.Credential( - deviceId = deviceId, - id = id.asString(), - oathType = when (oathType) { - OathType.HOTP -> Model.OathType.HOTP - else -> Model.OathType.TOTP - }, - period = period, - issuer = issuer, - accountName = accountName, - touchRequired = isTouchRequired -) - -fun Code.model() = Model.Code( - value, - validFrom / 1000, - validUntil / 1000 -) - -fun Map.model(deviceId: String): Map = - map { (credential, code) -> - Pair( - credential.model(deviceId), - code?.model() - ) - }.toMap() diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/Model.kt b/android/app/src/main/kotlin/com/yubico/authenticator/oath/Model.kt deleted file mode 100644 index aedc6abe..00000000 --- a/android/app/src/main/kotlin/com/yubico/authenticator/oath/Model.kt +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright (C) 2022 Yubico. - * - * 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.yubico.authenticator.oath - -import com.yubico.authenticator.device.Version -import kotlinx.serialization.* -import kotlinx.serialization.descriptors.PrimitiveKind -import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder - -class Model { - - @Serializable - data class Session( - @SerialName("device_id") - val deviceId: String, - @SerialName("version") - val version: Version, - @SerialName("has_key") - val isAccessKeySet: Boolean, - @SerialName("remembered") - val isRemembered: Boolean, - @SerialName("locked") - val isLocked: Boolean - ) { - @SerialName("keystore") - @Suppress("unused") - val keystoreState: String = "unknown" - } - - @Serializable(with = OathTypeSerializer::class) - enum class OathType(val value: Byte) { - TOTP(0x20), - HOTP(0x10); - } - - @Serializable - data class Credential( - @SerialName("device_id") - val deviceId: String, - val id: String, - @SerialName("oath_type") - val oathType: OathType, - val period: Int, - val issuer: String? = null, - @SerialName("name") - val accountName: String, - @SerialName("touch_required") - val touchRequired: Boolean - ) { - override fun equals(other: Any?): Boolean = - (other is Credential) && id == other.id && deviceId == other.deviceId - - override fun hashCode(): Int { - var result = deviceId.hashCode() - result = 31 * result + id.hashCode() - return result - } - } - - - @Serializable - data class Code( - val value: String? = null, - @SerialName("valid_from") - @Suppress("unused") - val validFrom: Long, - @SerialName("valid_to") - @Suppress("unused") - val validTo: Long - ) - - @Serializable - data class CredentialWithCode( - val credential: Credential, - val code: Code? - ) - - object OathTypeSerializer : KSerializer { - override fun deserialize(decoder: Decoder): OathType = - when (decoder.decodeByte()) { - OathType.HOTP.value -> OathType.HOTP - OathType.TOTP.value -> OathType.TOTP - else -> throw IllegalArgumentException() - } - - override val descriptor: SerialDescriptor = - PrimitiveSerialDescriptor("OathType", PrimitiveKind.BYTE) - - override fun serialize(encoder: Encoder, value: OathType) { - encoder.encodeByte(value = value.value) - } - - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt index 0479b960..7de5553d 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt @@ -29,6 +29,17 @@ import com.yubico.authenticator.device.Config import com.yubico.authenticator.device.Info import com.yubico.authenticator.device.Version import com.yubico.authenticator.logging.Log +import com.yubico.authenticator.oath.data.Code +import com.yubico.authenticator.oath.data.CodeType +import com.yubico.authenticator.oath.data.Credential +import com.yubico.authenticator.oath.data.CredentialWithCode +import com.yubico.authenticator.oath.data.Session +import com.yubico.authenticator.oath.data.YubiKitCode +import com.yubico.authenticator.oath.data.YubiKitCredential +import com.yubico.authenticator.oath.data.YubiKitOathSession +import com.yubico.authenticator.oath.data.YubiKitOathType +import com.yubico.authenticator.oath.data.calculateSteamCode +import com.yubico.authenticator.oath.data.isSteamCredential import com.yubico.authenticator.oath.keystore.ClearingMemProvider import com.yubico.authenticator.oath.keystore.KeyProvider import com.yubico.authenticator.oath.keystore.KeyStoreProvider @@ -45,7 +56,7 @@ import com.yubico.yubikit.core.smartcard.SmartCardConnection import com.yubico.yubikit.core.smartcard.SmartCardProtocol import com.yubico.yubikit.core.util.Result import com.yubico.yubikit.management.FormFactor -import com.yubico.yubikit.oath.* +import com.yubico.yubikit.oath.CredentialData import com.yubico.yubikit.support.DeviceUtil import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.MethodChannel @@ -55,7 +66,7 @@ import java.net.URI import java.util.concurrent.Executors import kotlin.coroutines.suspendCoroutine -typealias OathAction = (Result) -> Unit +typealias OathAction = (Result) -> Unit class OathManager( private val lifecycleOwner: LifecycleOwner, @@ -145,11 +156,11 @@ class OathManager( } } - private val credentialObserver = Observer?> { codes -> + private val credentialObserver = Observer?> { codes -> refreshJob?.cancel() if (codes != null && appViewModel.connectedYubiKey.value != null) { val expirations = codes - .filter { it.credential.oathType == Model.OathType.TOTP && !it.credential.touchRequired } + .filter { it.credential.codeType == CodeType.TOTP && !it.credential.touchRequired } .mapNotNull { it.code?.validTo } if (expirations.isNotEmpty()) { val earliest = expirations.min() * 1000 @@ -231,23 +242,21 @@ class OathManager( override suspend fun processYubiKey(device: YubiKeyDevice) { try { device.withConnection { connection -> - val oath = OathSession(connection) - tryToUnlockOathSession(oath) + val session = YubiKitOathSession(connection) + tryToUnlockOathSession(session) val previousId = oathViewModel.sessionState.value?.deviceId - if (oath.deviceId == previousId) { + if (session.deviceId == previousId) { // Run any pending action pendingAction?.let { action -> - action.invoke(Result.success(oath)) + action.invoke(Result.success(session)) pendingAction = null } // Refresh codes - if (!oath.isLocked) { + if (!session.isLocked) { try { - oathViewModel.updateCredentials( - calculateOathCodes(oath).model(oath.deviceId) - ) + oathViewModel.updateCredentials(calculateOathCodes(session)) } catch (error: Exception) { Log.e(TAG, "Failed to refresh codes", error.toString()) } @@ -259,11 +268,14 @@ class OathManager( } // Update the OATH state - oathViewModel.setSessionState(oath.model(keyManager.isRemembered(oath.deviceId))) - if (!oath.isLocked) { - oathViewModel.updateCredentials( - calculateOathCodes(oath).model(oath.deviceId) + oathViewModel.setSessionState( + Session( + session, + keyManager.isRemembered(session.deviceId) ) + ) + if (!session.isLocked) { + oathViewModel.updateCredentials(calculateOathCodes(session)) } // Awaiting an action for a different or no device? @@ -272,7 +284,7 @@ class OathManager( if (addToAny) { // Special "add to any YubiKey" action, process addToAny = false - action.invoke(Result.success(oath)) + action.invoke(Result.success(session)) } else { // Awaiting an action for a different device? Fail it and stop processing. action.invoke(Result.failure(IllegalStateException("Wrong deviceId"))) @@ -280,7 +292,7 @@ class OathManager( } } - if (oath.version.isLessThan(4, 0, 0) && connection.transport == Transport.NFC) { + if (session.version.isLessThan(4, 0, 0) && connection.transport == Transport.NFC) { // NEO over NFC, select OTP applet before reading info SmartCardProtocol(connection).select(OTP_AID) } @@ -358,14 +370,14 @@ class OathManager( val credential = session.putCredential(credentialData, requireTouch) val code = - if (credentialData.oathType == OathType.TOTP && !requireTouch) { + if (credentialData.oathType == YubiKitOathType.TOTP && !requireTouch) { // recalculate the code calculateCode(session, credential) } else null val addedCred = oathViewModel.addCredential( - credential.model(session.deviceId), - code?.model() + Credential(credential, session.deviceId), + Code.from(code) ) Log.d(TAG, "Added cred $credential") @@ -378,7 +390,7 @@ class OathManager( // note, it is ok to reset locked session it.reset() keyManager.removeKey(it.deviceId) - oathViewModel.setSessionState(it.model(false)) + oathViewModel.setSessionState(Session(it, false)) } return NULL } @@ -391,8 +403,8 @@ class OathManager( val unlocked = tryToUnlockOathSession(it) val remembered = keyManager.isRemembered(it.deviceId) if (unlocked) { - oathViewModel.setSessionState(it.model(remembered)) - oathViewModel.updateCredentials(calculateOathCodes(it).model(it.deviceId)) + oathViewModel.setSessionState(Session(it, remembered)) + oathViewModel.updateCredentials(calculateOathCodes(it)) } jsonSerializer.encodeToString(mapOf("unlocked" to unlocked, "remembered" to remembered)) @@ -415,7 +427,7 @@ class OathManager( val accessKey = session.deriveAccessKey(newPassword.toCharArray()) session.setAccessKey(accessKey) keyManager.addKey(session.deviceId, accessKey, false) - oathViewModel.setSessionState(session.model(false)) + oathViewModel.setSessionState(Session(session, false)) Log.d(TAG, "Successfully set password") NULL } @@ -427,7 +439,7 @@ class OathManager( if (session.unlock(currentPassword.toCharArray())) { session.deleteAccessKey() keyManager.removeKey(session.deviceId) - oathViewModel.setSessionState(session.model(false)) + oathViewModel.setSessionState(Session(session, false)) Log.d(TAG, "Successfully unset password") return@useOathSession NULL } @@ -460,14 +472,14 @@ class OathManager( val credential = session.putCredential(credentialData, requireTouch) val code = - if (credentialData.oathType == OathType.TOTP && !requireTouch) { + if (credentialData.oathType == YubiKitOathType.TOTP && !requireTouch) { // recalculate the code calculateCode(session, credential) } else null val addedCred = oathViewModel.addCredential( - credential.model(session.deviceId), - code?.model() + Credential(credential, session.deviceId), + Code.from(code) ) jsonSerializer.encodeToString(addedCred) @@ -477,9 +489,9 @@ class OathManager( useOathSession("Rename") { session -> val credential = getOathCredential(session, uri) val renamedCredential = - session.renameCredential(credential, name, issuer).model(session.deviceId) + Credential(session.renameCredential(credential, name, issuer), session.deviceId) oathViewModel.renameCredential( - credential.model(session.deviceId), + Credential(credential, session.deviceId), renamedCredential ) @@ -490,7 +502,7 @@ class OathManager( useOathSession("Delete account") { session -> val credential = getOathCredential(session, credentialId) session.deleteCredential(credential) - oathViewModel.removeCredential(credential.model(session.deviceId)) + oathViewModel.removeCredential(Credential(credential, session.deviceId)) NULL } @@ -498,13 +510,16 @@ class OathManager( appViewModel.connectedYubiKey.value?.let { usbYubiKeyDevice -> useOathSessionUsb(usbYubiKeyDevice) { session -> try { - oathViewModel.updateCredentials( - calculateOathCodes(session).model(session.deviceId) - ) + oathViewModel.updateCredentials(calculateOathCodes(session)) } catch (apduException: ApduException) { if (apduException.sw == SW.SECURITY_CONDITION_NOT_SATISFIED) { Log.d(TAG, "Handled oath credential refresh on locked session.") - oathViewModel.setSessionState(session.model(keyManager.isRemembered(session.deviceId))) + oathViewModel.setSessionState( + Session( + session, + keyManager.isRemembered(session.deviceId) + ) + ) } else { Log.e( TAG, @@ -520,9 +535,9 @@ class OathManager( useOathSession("Calculate") { session -> val credential = getOathCredential(session, credentialId) - val code = calculateCode(session, credential).model() + val code = Code.from(calculateCode(session, credential)) oathViewModel.updateCode( - credential.model(session.deviceId), + Credential(credential, session.deviceId), code ) Log.d(TAG, "Code calculated $code") @@ -532,15 +547,15 @@ class OathManager( /** * Returns Steam code or standard TOTP code based on the credential. - * @param session OathSession which calculates the TOTP code + * @param session YubiKitOathSession which calculates the TOTP code * @param credential * * @return calculated Code */ private fun calculateCode( - session: OathSession, - credential: Credential - ): Code { + session: YubiKitOathSession, + credential: YubiKitCredential + ): YubiKitCode { // Manual calculate, need to pad timer to avoid immediate expiration val timestamp = System.currentTimeMillis() + 10000 try { @@ -560,12 +575,12 @@ class OathManager( } /** - * Tries to unlocks [OathSession] with [AccessKey] stored in [KeyManager]. On failure clears + * Tries to unlocks [session] with access key stored in [KeyManager]. On failure clears * relevant access keys from [KeyManager] * * @return true if the session is not locked or it was successfully unlocked, false otherwise */ - private fun tryToUnlockOathSession(session: OathSession): Boolean { + private fun tryToUnlockOathSession(session: YubiKitOathSession): Boolean { if (!session.isLocked) { return true } @@ -584,7 +599,7 @@ class OathManager( return false // the unlock did not work, session is locked } - private fun calculateOathCodes(session: OathSession): Map { + private fun calculateOathCodes(session: YubiKitOathSession): Map { val isUsbKey = appViewModel.connectedYubiKey.value != null var timestamp = System.currentTimeMillis() if (!isUsbKey) { @@ -594,21 +609,21 @@ class OathManager( val bypassTouch = appPreferences.bypassTouchOnNfcTap && !isUsbKey return session.calculateCodes(timestamp).map { (credential, code) -> Pair( - credential, - if (credential.isSteamCredential() && (!credential.isTouchRequired || bypassTouch)) { + Credential(credential, session.deviceId), + Code.from(if (credential.isSteamCredential() && (!credential.isTouchRequired || bypassTouch)) { session.calculateSteamCode(credential, timestamp) } else if (credential.isTouchRequired && bypassTouch) { session.calculateCode(credential, timestamp) } else { code - } + }) ) }.toMap() } private suspend fun useOathSession( title: String, - action: (OathSession) -> T + action: (YubiKitOathSession) -> T ): T { return appViewModel.connectedYubiKey.value?.let { useOathSessionUsb(it, action) @@ -617,16 +632,16 @@ class OathManager( private suspend fun useOathSessionUsb( device: UsbYubiKeyDevice, - block: (OathSession) -> T + block: (YubiKitOathSession) -> T ): T = device.withConnection { - val oath = OathSession(it) - tryToUnlockOathSession(oath) - block(oath) + val session = YubiKitOathSession(it) + tryToUnlockOathSession(session) + block(session) } private suspend fun useOathSessionNfc( title: String, - block: (OathSession) -> T + block: (YubiKitOathSession) -> T ): T { try { val result = suspendCoroutine { outer -> @@ -664,9 +679,9 @@ class OathManager( } } - private fun getOathCredential(oathSession: OathSession, credentialId: String) = + private fun getOathCredential(session: YubiKitOathSession, credentialId: String) = // we need to use oathSession.calculateCodes() to get proper Credential.touchRequired value - oathSession.calculateCodes().map { e -> e.key }.firstOrNull { credential -> + session.calculateCodes().map { e -> e.key }.firstOrNull { credential -> (credential != null) && credential.id.asString() == credentialId } ?: throw Exception("Failed to find account") diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathViewModel.kt b/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathViewModel.kt index c996fd95..fcb8043f 100755 --- a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathViewModel.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathViewModel.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) 2022-2023 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,12 +19,16 @@ package com.yubico.authenticator.oath import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel +import com.yubico.authenticator.oath.data.Code +import com.yubico.authenticator.oath.data.Credential +import com.yubico.authenticator.oath.data.CredentialWithCode +import com.yubico.authenticator.oath.data.Session class OathViewModel: ViewModel() { - private val _sessionState = MutableLiveData() - val sessionState: LiveData = _sessionState + private val _sessionState = MutableLiveData() + val sessionState: LiveData = _sessionState - fun setSessionState(sessionState: Model.Session?) { + fun setSessionState(sessionState: Session?) { val oldDeviceId = _sessionState.value?.deviceId _sessionState.postValue(sessionState) if(oldDeviceId != sessionState?.deviceId) { @@ -32,14 +36,14 @@ class OathViewModel: ViewModel() { } } - private val _credentials = MutableLiveData?>() - val credentials: LiveData?> = _credentials + private val _credentials = MutableLiveData?>() + val credentials: LiveData?> = _credentials - fun updateCredentials(credentials: Map): List { + fun updateCredentials(credentials: Map): List { val existing = _credentials.value?.associate { it.credential to it.code } ?: mapOf() val updated = credentials.map { - Model.CredentialWithCode(it.key, it.value ?: existing[it.key]) + CredentialWithCode(it.key, it.value ?: existing[it.key]) } _credentials.postValue(updated) @@ -47,36 +51,36 @@ class OathViewModel: ViewModel() { return updated } - fun addCredential(credential: Model.Credential, code: Model.Code?): Model.CredentialWithCode { + fun addCredential(credential: Credential, code: Code?): CredentialWithCode { require(credential.deviceId == _sessionState.value?.deviceId) { "Cannot add credential for different deviceId" } - return Model.CredentialWithCode(credential, code).also { + return CredentialWithCode(credential, code).also { _credentials.postValue(_credentials.value?.plus(it)) } } fun renameCredential( - oldCredential: Model.Credential, - newCredential: Model.Credential + oldCredential: Credential, + newCredential: Credential ) { val existing = _credentials.value!! val entry = existing.find { it.credential == oldCredential }!! require(entry.credential.deviceId == newCredential.deviceId) { "Cannot rename credential for different deviceId" } - _credentials.postValue(existing.minus(entry).plus(Model.CredentialWithCode(newCredential, entry.code))) + _credentials.postValue(existing.minus(entry).plus(CredentialWithCode(newCredential, entry.code))) } - fun removeCredential(credential: Model.Credential) { + fun removeCredential(credential: Credential) { val existing = _credentials.value!! val entry = existing.find { it.credential == credential }!! _credentials.postValue(existing.minus(entry)) } - fun updateCode(credential: Model.Credential, code: Model.Code?) { + fun updateCode(credential: Credential, code: Code?) { val existing = _credentials.value!! val entry = existing.find { it.credential == credential }!! - _credentials.postValue(existing.minus(entry).plus(Model.CredentialWithCode(credential, code))) + _credentials.postValue(existing.minus(entry).plus(CredentialWithCode(credential, code))) } } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Code.kt b/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Code.kt new file mode 100644 index 00000000..a958c555 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Code.kt @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2023 Yubico. + * + * 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.yubico.authenticator.oath.data + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +typealias YubiKitCode = com.yubico.yubikit.oath.Code + +@Serializable +data class Code( + val value: String? = null, + @SerialName("valid_from") + @Suppress("unused") + val validFrom: Long, + @SerialName("valid_to") + @Suppress("unused") + val validTo: Long +) { + + companion object { + fun from(code: YubiKitCode?): Code? = + code?.let { + Code( + it.value, + it.validFrom / 1000, + it.validUntil / 1000 + ) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/CodeType.kt b/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/CodeType.kt new file mode 100644 index 00000000..bf7c9102 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/CodeType.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2023 Yubico. + * + * 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.yubico.authenticator.oath.data + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + + +@Serializable(with = OathTypeSerializer::class) +enum class CodeType(val value: Byte) { + TOTP(0x20), + HOTP(0x10); +} + +object OathTypeSerializer : KSerializer { + override fun deserialize(decoder: Decoder): CodeType = + when (decoder.decodeByte()) { + CodeType.HOTP.value -> CodeType.HOTP + CodeType.TOTP.value -> CodeType.TOTP + else -> throw IllegalArgumentException() + } + + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("OathType", PrimitiveKind.BYTE) + + override fun serialize(encoder: Encoder, value: CodeType) { + encoder.encodeByte(value = value.value) + } + +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Credential.kt b/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Credential.kt new file mode 100644 index 00000000..60c45ab9 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Credential.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2023 Yubico. + * + * 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.yubico.authenticator.oath.data + +import com.yubico.authenticator.asString +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +typealias YubiKitCredential = com.yubico.yubikit.oath.Credential +typealias YubiKitOathType = com.yubico.yubikit.oath.OathType + +@Serializable +data class Credential( + @SerialName("device_id") + val deviceId: String, + val id: String, + @SerialName("oath_type") + val codeType: CodeType, + val period: Int, + val issuer: String? = null, + @SerialName("name") + val accountName: String, + @SerialName("touch_required") + val touchRequired: Boolean +) { + + constructor(credential: YubiKitCredential, deviceId: String) : this( + deviceId = deviceId, + id = credential.id.asString(), + codeType = when (credential.oathType) { + YubiKitOathType.HOTP -> CodeType.HOTP + else -> CodeType.TOTP + }, + period = credential.period, + issuer = credential.issuer, + accountName = credential.accountName, + touchRequired = credential.isTouchRequired + ) + + override fun equals(other: Any?): Boolean = + (other is Credential) && + id == other.id && + deviceId == other.deviceId + + override fun hashCode(): Int { + var result = deviceId.hashCode() + result = 31 * result + id.hashCode() + return result + } +} + +@Serializable +data class CredentialWithCode( + val credential: Credential, + val code: Code? +) \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Session.kt b/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Session.kt new file mode 100644 index 00000000..905e04ea --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/Session.kt @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2023 Yubico. + * + * 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.yubico.authenticator.oath.data + +import com.yubico.authenticator.device.Version + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +typealias YubiKitOathSession = com.yubico.yubikit.oath.OathSession + +@Serializable +data class Session( + @SerialName("device_id") + val deviceId: String, + @SerialName("version") + val version: Version, + @SerialName("has_key") + val isAccessKeySet: Boolean, + @SerialName("remembered") + val isRemembered: Boolean, + @SerialName("locked") + val isLocked: Boolean +) { + @SerialName("keystore") + @Suppress("unused") + val keystoreState: String = "unknown" + + constructor(oathSession: YubiKitOathSession, isRemembered: Boolean) + : this( + oathSession.deviceId, + Version( + oathSession.version.major, + oathSession.version.minor, + oathSession.version.micro + ), + oathSession.isAccessKeySet, + isRemembered, + oathSession.isLocked + ) +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/SteamCredential.kt b/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/SteamCredential.kt similarity index 81% rename from android/app/src/main/kotlin/com/yubico/authenticator/oath/SteamCredential.kt rename to android/app/src/main/kotlin/com/yubico/authenticator/oath/data/SteamCredential.kt index c89f91e3..bba641a3 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/oath/SteamCredential.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/oath/data/SteamCredential.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) 2022-2023 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,20 +14,16 @@ * limitations under the License. */ -package com.yubico.authenticator.oath +package com.yubico.authenticator.oath.data -import com.yubico.yubikit.oath.Code -import com.yubico.yubikit.oath.Credential -import com.yubico.yubikit.oath.OathSession -import com.yubico.yubikit.oath.OathType import java.nio.ByteBuffer import kotlin.experimental.and /** * Returns true if this credential is considered to be Steam credential */ -fun Credential.isSteamCredential(): Boolean = - issuer == "Steam" && oathType == OathType.TOTP +fun YubiKitCredential.isSteamCredential(): Boolean = + issuer == "Steam" && oathType == YubiKitOathType.TOTP /** * @return Code with value formatted for use with Steam @@ -35,10 +31,10 @@ fun Credential.isSteamCredential(): Boolean = * @param timestamp the timestamp which is used for TOTP calculation * @throws IllegalArgumentException in case when the credential is not a Steam credential */ -fun OathSession.calculateSteamCode( - credential: Credential, +fun YubiKitOathSession.calculateSteamCode( + credential: YubiKitCredential, timestamp: Long, -): Code { +): YubiKitCode { val timeSlotMs = 30_000 require(credential.isSteamCredential()) { "This is not steam credential" @@ -46,7 +42,7 @@ fun OathSession.calculateSteamCode( val currentTimeSlot = timestamp / timeSlotMs - return Code( + return YubiKitCode( calculateResponse(credential.id, currentTimeSlot.toByteArray()).formatAsSteam(), currentTimeSlot * timeSlotMs, (currentTimeSlot + 1) * timeSlotMs diff --git a/android/app/src/test/java/com/yubico/authenticator/oath/ModelTest.kt b/android/app/src/test/java/com/yubico/authenticator/oath/ModelTest.kt index 6320c5f8..c367bc07 100644 --- a/android/app/src/test/java/com/yubico/authenticator/oath/ModelTest.kt +++ b/android/app/src/test/java/com/yubico/authenticator/oath/ModelTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) 2022-2023 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,6 +22,9 @@ import com.yubico.authenticator.oath.OathTestHelper.code import com.yubico.authenticator.oath.OathTestHelper.emptyCredentials import com.yubico.authenticator.oath.OathTestHelper.hotp import com.yubico.authenticator.oath.OathTestHelper.totp +import com.yubico.authenticator.oath.data.Code +import com.yubico.authenticator.oath.data.CodeType +import com.yubico.authenticator.oath.data.Session import org.junit.Assert.* import org.junit.Rule @@ -35,19 +38,21 @@ class ModelTest { private val viewModel = OathViewModel() private fun connectDevice(deviceId: String) { - viewModel.setSessionState(Model.Session( + viewModel.setSessionState( + Session( deviceId, Version(1, 2, 3), isAccessKeySet = false, isRemembered = false, isLocked = false - )) + ) + ) } @Test fun `uses RFC 6238 values`() { - assertEquals(0x10.toByte(), Model.OathType.HOTP.value) - assertEquals(0x20.toByte(), Model.OathType.TOTP.value) + assertEquals(0x10.toByte(), CodeType.HOTP.value) + assertEquals(0x20.toByte(), CodeType.TOTP.value) } @Test @@ -178,7 +183,7 @@ class ModelTest { fun `update without code preserves existing value`() { val d = "device" val totp = totp(d, name = "totpCred") - val totpCode: Model.Code? = null + val totpCode: Code? = null val hotp = hotp(d, name = "hotpCred") val hotpCode = code(value = "098765") @@ -204,7 +209,7 @@ class ModelTest { fun `update preserves interactive totp credentials`() { val d = "device" val totp = totp(d, name = "totpCred", touchRequired = true) - val totpCode: Model.Code? = null + val totpCode: Code? = null connectDevice(d) viewModel.updateCredentials(mapOf(totp to totpCode)) diff --git a/android/app/src/test/java/com/yubico/authenticator/oath/OathTestHelper.kt b/android/app/src/test/java/com/yubico/authenticator/oath/OathTestHelper.kt index 37607a69..ba1ac32d 100644 --- a/android/app/src/test/java/com/yubico/authenticator/oath/OathTestHelper.kt +++ b/android/app/src/test/java/com/yubico/authenticator/oath/OathTestHelper.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) 2022-2023 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,10 @@ package com.yubico.authenticator.oath +import com.yubico.authenticator.oath.data.Code +import com.yubico.authenticator.oath.data.CodeType +import com.yubico.authenticator.oath.data.Credential + object OathTestHelper { // create a TOTP credential with default or custom parameters @@ -27,7 +31,7 @@ object OathTestHelper { issuer: String? = nextIssuer(), touchRequired: Boolean = false, period: Int = 30 - ) = cred(deviceId, name, issuer, Model.OathType.TOTP, touchRequired, period) + ) = cred(deviceId, name, issuer, CodeType.TOTP, touchRequired, period) // create a HOTP credential with default or custom parameters // if not specified, default values for deviceId, name and issuer will use a unique value @@ -38,20 +42,20 @@ object OathTestHelper { issuer: String = nextIssuer(), touchRequired: Boolean = false, period: Int = 30 - ) = cred(deviceId, name, issuer, Model.OathType.HOTP, touchRequired, period) + ) = cred(deviceId, name, issuer, CodeType.HOTP, touchRequired, period) private fun cred( deviceId: String = nextDevice(), name: String = nextName(), issuer: String? = nextIssuer(), - type: Model.OathType, + type: CodeType, touchRequired: Boolean = false, period: Int = 30 ) = - Model.Credential( + Credential( deviceId = deviceId, id = """otpauth://${type.name}/${name}?secret=aabbaabbaabbaabb&issuer=${issuer}""", - oathType = type, + codeType = type, period = period, issuer = issuer, accountName = name, @@ -62,9 +66,9 @@ object OathTestHelper { value: String = "111111", from: Long = 1000, to: Long = 2000 - ) = Model.Code(value, from, to) + ) = Code(value, from, to) - fun emptyCredentials() = emptyMap() + fun emptyCredentials() = emptyMap() private var nameCounter = 0 private fun nextName(): String { diff --git a/android/app/src/test/java/com/yubico/authenticator/oath/SerializationTest.kt b/android/app/src/test/java/com/yubico/authenticator/oath/SerializationTest.kt index cf2dd48c..0c833721 100644 --- a/android/app/src/test/java/com/yubico/authenticator/oath/SerializationTest.kt +++ b/android/app/src/test/java/com/yubico/authenticator/oath/SerializationTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) 2022-2023 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,13 +21,21 @@ import com.yubico.authenticator.jsonSerializer import com.yubico.authenticator.oath.OathTestHelper.code import com.yubico.authenticator.oath.OathTestHelper.hotp import com.yubico.authenticator.oath.OathTestHelper.totp -import kotlinx.serialization.json.* -import org.junit.Assert.* +import com.yubico.authenticator.oath.data.CredentialWithCode +import com.yubico.authenticator.oath.data.Session +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class SerializationTest { - private val session = Model.Session( + private val session = Session( "device", Version(1, 2, 3), isAccessKeySet = false, @@ -49,7 +57,7 @@ class SerializationTest { @Test fun `session json property names`() { - val jsonObject : JsonObject = jsonSerializer.encodeToJsonElement(session) as JsonObject + val jsonObject: JsonObject = jsonSerializer.encodeToJsonElement(session) as JsonObject assertTrue(jsonObject.containsKey("device_id")) assertTrue(jsonObject.containsKey("has_key")) assertTrue(jsonObject.containsKey("remembered")) @@ -59,7 +67,7 @@ class SerializationTest { @Test fun `keystore is unknown`() { - val jsonObject : JsonObject = jsonSerializer.encodeToJsonElement(session) as JsonObject + val jsonObject: JsonObject = jsonSerializer.encodeToJsonElement(session) as JsonObject assertEquals("unknown", jsonObject["keystore"]?.jsonPrimitive?.content) } @@ -75,7 +83,7 @@ class SerializationTest { fun `credential json property names`() { val c = totp() - val jsonObject : JsonObject = jsonSerializer.encodeToJsonElement(c) as JsonObject + val jsonObject: JsonObject = jsonSerializer.encodeToJsonElement(c) as JsonObject assertTrue(jsonObject.containsKey("device_id")) assertTrue(jsonObject.containsKey("id")) @@ -98,7 +106,7 @@ class SerializationTest { fun `code json property names`() { val c = code() - val jsonObject : JsonObject = jsonSerializer.encodeToJsonElement(c) as JsonObject + val jsonObject: JsonObject = jsonSerializer.encodeToJsonElement(c) as JsonObject assertTrue(jsonObject.containsKey("value")) assertTrue(jsonObject.containsKey("valid_from")) @@ -109,7 +117,7 @@ class SerializationTest { fun `code json content`() { val c = code(value = "001122", from = 1000, to = 2000) - val jsonObject : JsonObject = jsonSerializer.encodeToJsonElement(c) as JsonObject + val jsonObject: JsonObject = jsonSerializer.encodeToJsonElement(c) as JsonObject assertEquals(JsonPrimitive(1000), jsonObject["valid_from"]) assertEquals(JsonPrimitive(2000), jsonObject["valid_to"]) @@ -119,7 +127,7 @@ class SerializationTest { @Test fun `credentials json type`() { val l = listOf( - Model.CredentialWithCode(totp(), code()), Model.CredentialWithCode(hotp(), code()), + CredentialWithCode(totp(), code()), CredentialWithCode(hotp(), code()), ) val jsonElement = jsonSerializer.encodeToJsonElement(l) @@ -128,12 +136,12 @@ class SerializationTest { @Test fun `credentials json size`() { - val l1 = listOf() + val l1 = listOf() val jsonElement1 = jsonSerializer.encodeToJsonElement(l1) as JsonArray assertEquals(0, jsonElement1.size) val l2 = listOf( - Model.CredentialWithCode(totp(), code()), Model.CredentialWithCode(hotp(), code()), + CredentialWithCode(totp(), code()), CredentialWithCode(hotp(), code()), ) val jsonElement2 = jsonSerializer.encodeToJsonElement(l2) as JsonArray assertEquals(2, jsonElement2.size) diff --git a/android/app/src/test/java/com/yubico/authenticator/oath/SteamCredentialTest.kt b/android/app/src/test/java/com/yubico/authenticator/oath/SteamCredentialTest.kt index a47af9b8..cd96580a 100644 --- a/android/app/src/test/java/com/yubico/authenticator/oath/SteamCredentialTest.kt +++ b/android/app/src/test/java/com/yubico/authenticator/oath/SteamCredentialTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Yubico. + * Copyright (C) 2022-2023 Yubico. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,9 +16,11 @@ package com.yubico.authenticator.oath -import com.yubico.yubikit.oath.Credential -import com.yubico.yubikit.oath.OathSession -import com.yubico.yubikit.oath.OathType +import com.yubico.authenticator.oath.data.YubiKitCredential +import com.yubico.authenticator.oath.data.YubiKitOathSession +import com.yubico.authenticator.oath.data.YubiKitOathType +import com.yubico.authenticator.oath.data.calculateSteamCode +import com.yubico.authenticator.oath.data.isSteamCredential import org.junit.Assert import org.junit.Test import org.mockito.Mockito.* @@ -27,26 +29,26 @@ class SteamCredentialTest { @Test fun `recognize Steam credential`() { - val c = mock(Credential::class.java) - `when`(c.oathType).thenReturn(OathType.TOTP) + val c = mock(YubiKitCredential::class.java) + `when`(c.oathType).thenReturn(YubiKitOathType.TOTP) `when`(c.issuer).thenReturn("Steam") Assert.assertTrue(c.isSteamCredential()) - `when`(c.oathType).thenReturn(OathType.HOTP) + `when`(c.oathType).thenReturn(YubiKitOathType.HOTP) `when`(c.issuer).thenReturn("Steam") Assert.assertFalse(c.isSteamCredential()) - `when`(c.oathType).thenReturn(OathType.TOTP) + `when`(c.oathType).thenReturn(YubiKitOathType.TOTP) `when`(c.issuer).thenReturn(null) Assert.assertFalse(c.isSteamCredential()) } @Test(expected = IllegalArgumentException::class) fun `throw for non-Steam credential`() { - val s = mock(OathSession::class.java) + val s = mock(YubiKitOathSession::class.java) - val c = mock(Credential::class.java) - `when`(c.oathType).thenReturn(OathType.HOTP) + val c = mock(YubiKitCredential::class.java) + `when`(c.oathType).thenReturn(YubiKitOathType.HOTP) `when`(c.issuer).thenReturn("Steam") s.calculateSteamCode(c, 0) @@ -98,7 +100,7 @@ class SteamCredentialTest { // OathSession always calculating specific response private fun sessionWith(response: String) = - mock(OathSession::class.java).also { + mock(YubiKitOathSession::class.java).also { `when`( it.calculateResponse( isA(ByteArray::class.java), @@ -108,8 +110,8 @@ class SteamCredentialTest { } // valid Steam Credential mock - private fun steamCredential() = mock(Credential::class.java).also { - `when`(it.oathType).thenReturn(OathType.TOTP) + private fun steamCredential() = mock(YubiKitCredential::class.java).also { + `when`(it.oathType).thenReturn(YubiKitOathType.TOTP) `when`(it.issuer).thenReturn("Steam") `when`(it.id).thenReturn("id".toByteArray()) }