separate yubikit and app's types

This commit is contained in:
Adam Velebil 2023-01-27 11:27:23 +01:00
parent 21085b5637
commit 26d0635c92
No known key found for this signature in database
GPG Key ID: C9B1E4A3CBBD2E10
14 changed files with 399 additions and 303 deletions

View File

@ -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) }

View File

@ -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<Credential, Code?>.model(deviceId: String): Map<Model.Credential, Model.Code?> =
map { (credential, code) ->
Pair(
credential.model(deviceId),
code?.model()
)
}.toMap()

View File

@ -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<OathType> {
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)
}
}
}

View File

@ -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<OathSession, Exception>) -> Unit
typealias OathAction = (Result<YubiKitOathSession, Exception>) -> Unit
class OathManager(
private val lifecycleOwner: LifecycleOwner,
@ -145,11 +156,11 @@ class OathManager(
}
}
private val credentialObserver = Observer<List<Model.CredentialWithCode>?> { codes ->
private val credentialObserver = Observer<List<CredentialWithCode>?> { 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<SmartCardConnection, Unit> { 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<Credential, Code> {
private fun calculateOathCodes(session: YubiKitOathSession): Map<Credential, Code?> {
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 <T> 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 <T> useOathSessionUsb(
device: UsbYubiKeyDevice,
block: (OathSession) -> T
block: (YubiKitOathSession) -> T
): T = device.withConnection<SmartCardConnection, T> {
val oath = OathSession(it)
tryToUnlockOathSession(oath)
block(oath)
val session = YubiKitOathSession(it)
tryToUnlockOathSession(session)
block(session)
}
private suspend fun <T> 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")

View File

@ -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<Model.Session?>()
val sessionState: LiveData<Model.Session?> = _sessionState
private val _sessionState = MutableLiveData<Session?>()
val sessionState: LiveData<Session?> = _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<List<Model.CredentialWithCode>?>()
val credentials: LiveData<List<Model.CredentialWithCode>?> = _credentials
private val _credentials = MutableLiveData<List<CredentialWithCode>?>()
val credentials: LiveData<List<CredentialWithCode>?> = _credentials
fun updateCredentials(credentials: Map<Model.Credential, Model.Code?>): List<Model.CredentialWithCode> {
fun updateCredentials(credentials: Map<Credential, Code?>): List<CredentialWithCode> {
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)))
}
}

View File

@ -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
)
}
}
}

View File

@ -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<CodeType> {
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)
}
}

View File

@ -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?
)

View File

@ -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
)
}

View File

@ -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

View File

@ -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))

View File

@ -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<Model.Credential, Model.Code>()
fun emptyCredentials() = emptyMap<Credential, Code>()
private var nameCounter = 0
private fun nextName(): String {

View File

@ -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<Model.CredentialWithCode>()
val l1 = listOf<CredentialWithCode>()
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)

View File

@ -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())
}