mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-12-25 11:12:10 +03:00
Merge PR #114.
This commit is contained in:
commit
a9c94e13ef
@ -13,20 +13,22 @@ if (flutterRoot == null) {
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
//noinspection GroovyUnusedAssignment
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
//noinspection GroovyUnusedAssignment
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
def authenticatorVersionCode = 59900
|
||||
def authenticatorVersionName = "6.0.0-alpha.2"
|
||||
def authenticatorVersionName = '6.0.0-alpha.2'
|
||||
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: "org.jetbrains.kotlin.plugin.serialization"
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||
|
||||
android {
|
||||
@ -80,11 +82,12 @@ dependencies {
|
||||
api "com.yubico.yubikit:oath:$project.yubiKitVersion"
|
||||
api "com.yubico.yubikit:support:$project.yubiKitVersion"
|
||||
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-core:1.0.0-RC'
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
|
||||
|
||||
// Lifecycle
|
||||
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1"
|
||||
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0"
|
||||
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1'
|
||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
|
||||
|
||||
implementation 'androidx.fragment:fragment-ktx:1.4.1'
|
||||
|
||||
|
41
android/app/proguard-rules.pro
vendored
41
android/app/proguard-rules.pro
vendored
@ -6,3 +6,44 @@
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
-dontwarn edu.umd.cs.findbugs.annotations.SuppressFBWarnings
|
||||
|
||||
|
||||
# Keep `Companion` object fields of serializable classes.
|
||||
# This avoids serializer lookup through `getDeclaredClasses` as done for named companion objects.
|
||||
-if @kotlinx.serialization.Serializable class **
|
||||
-keepclassmembers class <1> {
|
||||
static <1>$Companion Companion;
|
||||
}
|
||||
|
||||
# Keep `serializer()` on companion objects (both default and named) of serializable classes.
|
||||
-if @kotlinx.serialization.Serializable class ** {
|
||||
static **$* *;
|
||||
}
|
||||
-keepclassmembers class <2>$<3> {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# Keep `INSTANCE.serializer()` of serializable objects.
|
||||
-if @kotlinx.serialization.Serializable class ** {
|
||||
public static ** INSTANCE;
|
||||
}
|
||||
-keepclassmembers class <1> {
|
||||
public static <1> INSTANCE;
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# @Serializable and @Polymorphic are used at runtime for polymorphic serialization.
|
||||
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault
|
||||
|
||||
# Serializer for classes with named companion objects are retrieved using `getDeclaredClasses`.
|
||||
# If you have any, uncomment and replace classes with those containing named companion objects.
|
||||
#-keepattributes InnerClasses # Needed for `getDeclaredClasses`.
|
||||
#-if @kotlinx.serialization.Serializable class
|
||||
#com.example.myapplication.HasNamedCompanion, # <-- List serializable classes with named companions.
|
||||
#com.example.myapplication.HasNamedCompanion2
|
||||
#{
|
||||
# static **$* *;
|
||||
#}
|
||||
#-keepnames class <1>$$serializer { # -keepnames suffices; class is kept when serializer() is kept.
|
||||
# static <1>$$serializer INSTANCE;
|
||||
#}
|
@ -2,56 +2,36 @@ package com.yubico.authenticator.oath
|
||||
|
||||
import com.yubico.yubikit.oath.Code
|
||||
import com.yubico.yubikit.oath.Credential
|
||||
import com.yubico.yubikit.oath.OathSession
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import com.yubico.yubikit.oath.OathType
|
||||
|
||||
fun OathSession.toJson(remembered: Boolean) = JsonObject(
|
||||
mapOf(
|
||||
"deviceId" to JsonPrimitive(deviceId),
|
||||
"hasKey" to JsonPrimitive(isAccessKeySet),
|
||||
"remembered" to JsonPrimitive(remembered),
|
||||
"locked" to JsonPrimitive(isLocked)
|
||||
)
|
||||
)
|
||||
|
||||
fun Code.toJson() = JsonObject(
|
||||
mapOf(
|
||||
"value" to JsonPrimitive(value),
|
||||
"valid_from" to JsonPrimitive(validFrom / 1000),
|
||||
"valid_to" to JsonPrimitive(validUntil / 1000)
|
||||
)
|
||||
)
|
||||
|
||||
fun Credential.idAsString() = id.joinToString(
|
||||
fun ByteArray.asString() = joinToString(
|
||||
separator = ""
|
||||
) { b -> "%02x".format(b) }
|
||||
|
||||
fun Credential.toJson(deviceId: String) = JsonObject(
|
||||
mapOf(
|
||||
"id" to JsonPrimitive(idAsString()),
|
||||
"device_id" to JsonPrimitive(deviceId),
|
||||
"issuer" to JsonPrimitive(issuer),
|
||||
"name" to JsonPrimitive(accountName),
|
||||
"oath_type" to JsonPrimitive(oathType.value),
|
||||
"period" to JsonPrimitive(period),
|
||||
"touch_required" to JsonPrimitive(isTouchRequired),
|
||||
)
|
||||
// convert yubikit types to Model types
|
||||
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 Map<Credential, Code?>.toJson(deviceId: String) = JsonObject(
|
||||
mapOf(
|
||||
"entries" to JsonArray(
|
||||
map { it.toPair().toJson(deviceId) }
|
||||
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()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
fun Pair<Credential, Code?>.toJson(deviceId: String) = JsonObject(
|
||||
mapOf(
|
||||
"credential" to first.toJson(deviceId),
|
||||
"code" to (second?.toJson() ?: JsonNull)
|
||||
)
|
||||
)
|
||||
}.toMap()
|
||||
|
@ -0,0 +1,8 @@
|
||||
package com.yubico.authenticator.oath
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
val jsonSerializer = Json {
|
||||
// creates properties for default values
|
||||
encodeDefaults = true
|
||||
}
|
@ -0,0 +1,185 @@
|
||||
package com.yubico.authenticator.oath
|
||||
|
||||
import kotlinx.serialization.KSerializer
|
||||
import kotlinx.serialization.SerialName
|
||||
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
|
||||
|
||||
fun Model.Credential.isInteractive(): Boolean {
|
||||
return oathType == Model.OathType.HOTP || (oathType == Model.OathType.TOTP && touchRequired)
|
||||
}
|
||||
|
||||
class Model {
|
||||
|
||||
@Serializable
|
||||
data class Session(
|
||||
@SerialName("device_id")
|
||||
val deviceId: String = "",
|
||||
@SerialName("has_key")
|
||||
val isAccessKeySet: Boolean = false,
|
||||
@SerialName("remembered")
|
||||
val isRemembered: Boolean = false,
|
||||
@SerialName("locked")
|
||||
val isLocked: Boolean = false,
|
||||
@SerialName("keystore")
|
||||
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
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private var _credentials = mutableMapOf<Credential, Code?>(); private set
|
||||
|
||||
var session = Session()
|
||||
val credentials: List<CredentialWithCode>
|
||||
get() = _credentials.map {
|
||||
CredentialWithCode(it.key, it.value)
|
||||
}
|
||||
|
||||
// resets the model to initial values
|
||||
// used when a usb key has been disconnected
|
||||
fun reset() {
|
||||
this._credentials.clear()
|
||||
this.session = Session()
|
||||
}
|
||||
|
||||
fun update(deviceId: String, credentials: Map<Credential, Code?>) {
|
||||
if (this.session.deviceId != deviceId) {
|
||||
// device was changed, we use the new list
|
||||
this._credentials.clear()
|
||||
this._credentials.putAll(from = credentials)
|
||||
this.session = Session(deviceId)
|
||||
} else {
|
||||
|
||||
// update codes for non interactive keys
|
||||
for ((credential, code) in credentials) {
|
||||
if (!credential.isInteractive()) {
|
||||
this._credentials[credential] = code
|
||||
}
|
||||
}
|
||||
// remove obsolete credentials
|
||||
this._credentials.filter { entry ->
|
||||
// get only keys which are not present in the input map
|
||||
!credentials.contains(entry.key)
|
||||
}.forEach(action = {
|
||||
this._credentials.remove(it.key)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun add(deviceId: String, credential: Credential, code: Code?): CredentialWithCode? {
|
||||
if (this.session.deviceId != deviceId) {
|
||||
return null
|
||||
}
|
||||
|
||||
_credentials[credential] = code
|
||||
|
||||
return CredentialWithCode(credential, code)
|
||||
}
|
||||
|
||||
fun rename(
|
||||
deviceId: String,
|
||||
oldCredential: Credential,
|
||||
newCredential: Credential
|
||||
): Credential? {
|
||||
if (this.session.deviceId != deviceId) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (oldCredential.deviceId != newCredential.deviceId) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!_credentials.contains(oldCredential)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// preserve code
|
||||
val code = _credentials[oldCredential]
|
||||
|
||||
_credentials.remove(oldCredential)
|
||||
_credentials[newCredential] = code
|
||||
|
||||
return newCredential
|
||||
}
|
||||
|
||||
fun updateCode(deviceId: String, credential: Credential, code: Code?): Code? {
|
||||
if (this.session.deviceId != deviceId) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!_credentials.contains(credential)) {
|
||||
return null
|
||||
}
|
||||
|
||||
_credentials[credential] = code
|
||||
|
||||
return code
|
||||
}
|
||||
}
|
@ -18,6 +18,7 @@ import com.yubico.yubikit.oath.*
|
||||
import com.yubico.yubikit.support.DeviceUtil
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.serialization.encodeToString
|
||||
import java.net.URI
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.coroutines.resume
|
||||
@ -45,6 +46,8 @@ class OathManager(
|
||||
private val _pendingYubiKeyAction = MutableLiveData<YubiKeyAction?>()
|
||||
private val pendingYubiKeyAction: LiveData<YubiKeyAction?> = _pendingYubiKeyAction
|
||||
|
||||
private val _model = Model()
|
||||
|
||||
init {
|
||||
OathApi.setup(messenger, this)
|
||||
|
||||
@ -112,6 +115,7 @@ class OathManager(
|
||||
_memoryKeyProvider.clearAll()
|
||||
_pendingYubiKeyAction.postValue(null)
|
||||
_fManagementApi.updateDeviceInfo("") {}
|
||||
_model.reset()
|
||||
}
|
||||
}
|
||||
|
||||
@ -147,9 +151,8 @@ class OathManager(
|
||||
isRemembered = _keyManager.isRemembered(it.deviceId)
|
||||
}
|
||||
if (response.isUnlocked == true) {
|
||||
codes = calculateOathCodes(it)
|
||||
.toJson(it.deviceId)
|
||||
.toString()
|
||||
_model.update(it.deviceId, calculateOathCodes(it).model(it.deviceId))
|
||||
codes = jsonSerializer.encodeToString(_model.credentials)
|
||||
}
|
||||
returnSuccess(result, response)
|
||||
}
|
||||
@ -243,11 +246,19 @@ class OathManager(
|
||||
calculateCode(session, credential, System.currentTimeMillis())
|
||||
} else null
|
||||
|
||||
val jsonResult = Pair<Credential, Code?>(credential, code)
|
||||
.toJson(session.deviceId)
|
||||
.toString()
|
||||
val addedCred = _model.add(
|
||||
session.deviceId,
|
||||
credential.model(session.deviceId),
|
||||
code?.model()
|
||||
)
|
||||
|
||||
returnSuccess(result, jsonResult)
|
||||
if (addedCred != null) {
|
||||
val jsonResult = jsonSerializer.encodeToString(addedCred)
|
||||
returnSuccess(result, jsonResult)
|
||||
} else {
|
||||
// TODO - figure out better error handling here
|
||||
returnError(result, java.lang.IllegalStateException())
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (cause: Throwable) {
|
||||
@ -263,12 +274,21 @@ class OathManager(
|
||||
withUnlockedSession(session) {
|
||||
val credential = getOathCredential(session, uri)
|
||||
|
||||
val jsonResult =
|
||||
session.renameCredential(credential, name, issuer)
|
||||
.toJson(session.deviceId)
|
||||
.toString()
|
||||
val renamedCredential = _model.rename(
|
||||
it.deviceId,
|
||||
credential.model(it.deviceId),
|
||||
session.renameCredential(credential, name, issuer).model(it.deviceId)
|
||||
)
|
||||
|
||||
returnSuccess(result, jsonResult)
|
||||
if (renamedCredential != null) {
|
||||
val jsonResult =
|
||||
jsonSerializer.encodeToString(renamedCredential)
|
||||
|
||||
returnSuccess(result, jsonResult)
|
||||
} else {
|
||||
// TODO - figure out better error handling here
|
||||
returnError(result, java.lang.IllegalStateException())
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (cause: Throwable) {
|
||||
@ -298,9 +318,12 @@ class OathManager(
|
||||
|
||||
useOathSession("Refresh codes", false) {
|
||||
withUnlockedSession(it) { session ->
|
||||
val resultJson = calculateOathCodes(session)
|
||||
.toJson(session.deviceId)
|
||||
.toString()
|
||||
|
||||
_model.update(
|
||||
session.deviceId,
|
||||
calculateOathCodes(session).model(session.deviceId)
|
||||
)
|
||||
val resultJson = jsonSerializer.encodeToString(_model.credentials)
|
||||
returnSuccess(result, resultJson)
|
||||
}
|
||||
}
|
||||
@ -318,12 +341,20 @@ class OathManager(
|
||||
|
||||
val credential = getOathCredential(session, uri)
|
||||
|
||||
val resultJson =
|
||||
calculateCode(session, credential, System.currentTimeMillis())
|
||||
.toJson()
|
||||
.toString()
|
||||
val code = _model.updateCode(
|
||||
session.deviceId,
|
||||
credential.model(session.deviceId),
|
||||
calculateCode(session, credential, System.currentTimeMillis()).model()
|
||||
)
|
||||
|
||||
returnSuccess(result, resultJson)
|
||||
if (code != null) {
|
||||
val resultJson = jsonSerializer.encodeToString(code)
|
||||
|
||||
returnSuccess(result, resultJson)
|
||||
} else {
|
||||
// TODO - figure out better error handling here
|
||||
returnError(result, java.lang.IllegalStateException())
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (cause: Throwable) {
|
||||
@ -396,9 +427,14 @@ class OathManager(
|
||||
tryToUnlockOathSession(oathSession)
|
||||
val isRemembered = _keyManager.isRemembered(oathSession.deviceId)
|
||||
|
||||
val oathSessionData = oathSession
|
||||
.toJson(isRemembered)
|
||||
.toString()
|
||||
_model.session = Model.Session(
|
||||
oathSession.deviceId,
|
||||
oathSession.isAccessKeySet,
|
||||
isRemembered,
|
||||
oathSession.isLocked
|
||||
)
|
||||
|
||||
val oathSessionData = jsonSerializer.encodeToString(_model.session)
|
||||
it.resume(oathSessionData)
|
||||
}
|
||||
}
|
||||
@ -411,9 +447,11 @@ class OathManager(
|
||||
device.requestConnection(SmartCardConnection::class.java) { result ->
|
||||
val session = OathSession(result.value)
|
||||
if (tryToUnlockOathSession(session)) {
|
||||
val resultJson = calculateOathCodes(session)
|
||||
.toJson(session.deviceId)
|
||||
.toString()
|
||||
_model.update(
|
||||
session.deviceId,
|
||||
calculateOathCodes(session).model(session.deviceId)
|
||||
)
|
||||
val resultJson = jsonSerializer.encodeToString(_model.credentials)
|
||||
it.resume(resultJson)
|
||||
}
|
||||
}
|
||||
@ -503,7 +541,7 @@ class OathManager(
|
||||
|
||||
private fun getOathCredential(oathSession: OathSession, credentialId: String) =
|
||||
oathSession.credentials.firstOrNull { credential ->
|
||||
(credential != null) && credential.idAsString() == credentialId
|
||||
(credential != null) && credential.id.asString() == credentialId
|
||||
} ?: throw Exception("Failed to find account to delete")
|
||||
|
||||
|
||||
|
@ -0,0 +1,316 @@
|
||||
package com.yubico.authenticator.oath
|
||||
|
||||
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 org.junit.Assert.*
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
class ModelTest {
|
||||
|
||||
private val model = Model()
|
||||
|
||||
@Test
|
||||
fun `uses RFC 6238 values`() {
|
||||
assertEquals(0x10.toByte(), Model.OathType.HOTP.value)
|
||||
assertEquals(0x20.toByte(), Model.OathType.TOTP.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hotp is interactive`() {
|
||||
assertTrue(hotp().isInteractive())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `totp with touch is interactive`() {
|
||||
assertTrue(totp(touchRequired = true).isInteractive())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `totp without touch is not interactive`() {
|
||||
assertFalse(totp(touchRequired = false).isInteractive())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `has no credentials after initialization`() {
|
||||
assertTrue(model.credentials.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updates empty model`() {
|
||||
|
||||
val d = "device1"
|
||||
val m = mapOf(totp(d) to code())
|
||||
model.update(d, m)
|
||||
|
||||
assertEquals(1, model.credentials.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tracks deviceId`() {
|
||||
val noDevice = ""
|
||||
val device1 = "d1"
|
||||
val device2 = "d2"
|
||||
assertEquals(noDevice, model.session.deviceId)
|
||||
model.update(device1, emptyCredentials())
|
||||
assertEquals(device1, model.session.deviceId)
|
||||
model.update(device2, emptyCredentials())
|
||||
assertEquals(device2, model.session.deviceId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replaces credentials on device change`() {
|
||||
val d1 = "device1"
|
||||
val m1 = mapOf(
|
||||
totp(d1) to code(),
|
||||
totp(d1) to code()
|
||||
)
|
||||
model.update(d1, m1)
|
||||
|
||||
val d2 = "device2"
|
||||
val m2 = emptyCredentials()
|
||||
model.update(d2, m2)
|
||||
|
||||
assertTrue(model.credentials.isEmpty())
|
||||
|
||||
model.update(d1, m1)
|
||||
assertEquals(2, model.credentials.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preserves credentials on update`() {
|
||||
|
||||
val d1 = "device1"
|
||||
|
||||
val cred1 = totp(d1, name = "cred1")
|
||||
val cred2 = totp(d1, name = "cred2")
|
||||
val cred3 = totp(d1, name = "cred3")
|
||||
|
||||
val m1 = mapOf(
|
||||
cred1 to code(),
|
||||
cred2 to code()
|
||||
)
|
||||
model.update(d1, m1)
|
||||
|
||||
// one more credential was added
|
||||
val m2 = mapOf(
|
||||
cred2 to code(),
|
||||
cred3 to code(),
|
||||
cred1 to code()
|
||||
)
|
||||
|
||||
model.update(d1, m2)
|
||||
|
||||
assertEquals("device1", model.session.deviceId)
|
||||
assertEquals(3, model.credentials.size)
|
||||
assertTrue(model.credentials.find { it.credential == cred1 } != null)
|
||||
assertTrue(model.credentials.find { it.credential == cred2 } != null)
|
||||
assertTrue(model.credentials.find { it.credential == cred3 } != null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updates credential codes`() {
|
||||
val cred = totp(name = "cred1")
|
||||
val code = code(value = "123456")
|
||||
val m1 = mapOf(cred to code)
|
||||
model.update(cred.deviceId, m1)
|
||||
|
||||
assertTrue(model.credentials.find { it.code == code } != null)
|
||||
|
||||
val updatedCode = code(value = "121212")
|
||||
val m2 = mapOf(cred to updatedCode)
|
||||
model.update(cred.deviceId, m2)
|
||||
|
||||
assertTrue(model.credentials.find { it.code == updatedCode } != null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `update preserves non-interactive codes`() {
|
||||
val d = "device"
|
||||
val totp = totp(d, name = "totpCred")
|
||||
val totpCode: Model.Code? = null
|
||||
|
||||
val hotp = hotp(d, name = "hotpCred")
|
||||
val hotpCode: Model.Code? = null
|
||||
|
||||
val m1 = mapOf(hotp to hotpCode, totp to totpCode)
|
||||
model.update(d, m1)
|
||||
|
||||
assertTrue(model.credentials.find { it.code == hotpCode } != null)
|
||||
|
||||
val updatedTotpCode = code(value = "121212")
|
||||
val updatedHotpCode = code(value = "098765")
|
||||
val m2 = mapOf(hotp to updatedHotpCode, totp to updatedTotpCode)
|
||||
model.update(d, m2)
|
||||
|
||||
assertTrue(model.credentials.find { it.code == updatedTotpCode } != null)
|
||||
assertTrue(model.credentials.find { it.code == hotpCode } != null)
|
||||
assertFalse(model.credentials.find { it.code == updatedHotpCode } != null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `update preserves interactive totp credentials`() {
|
||||
val d = "device"
|
||||
val totp = totp(d, name = "totpCred", touchRequired = true)
|
||||
val totpCode: Model.Code? = null
|
||||
|
||||
model.update(d, mapOf(totp to totpCode))
|
||||
|
||||
// simulate touch
|
||||
val newCode = model.updateCode(d, totp, code(value = "00000"))
|
||||
assertNotNull(newCode)
|
||||
|
||||
// update with same values
|
||||
model.update(d, mapOf(totp to newCode))
|
||||
|
||||
assertEquals(1, model.credentials.size)
|
||||
assertEquals("00000", model.credentials.find { it.credential == totp }?.code?.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adds new credentials`() {
|
||||
val d = "Device"
|
||||
val t1 = totp()
|
||||
val c1 = code()
|
||||
model.update(d, mapOf(t1 to c1))
|
||||
|
||||
val t2 = totp()
|
||||
val c2 = code()
|
||||
val t3 = totp()
|
||||
val c3 = code()
|
||||
model.update(d, mapOf(t3 to c3, t2 to c2, t1 to c1))
|
||||
|
||||
// t3 and t2 are added to credentials
|
||||
assertEquals(3, model.credentials.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removes non-existing credentials`() {
|
||||
val d = "Device"
|
||||
val t1 = totp()
|
||||
val c1 = code()
|
||||
val t2 = totp()
|
||||
val c2 = code()
|
||||
val t3 = totp()
|
||||
val c3 = code()
|
||||
|
||||
model.update(d, mapOf(t3 to c3, t1 to c1, t2 to c2))
|
||||
assertEquals(3, model.credentials.size)
|
||||
|
||||
model.update(d, mapOf(t1 to c1))
|
||||
|
||||
// only t1 is part of credentials
|
||||
assertEquals(1, model.credentials.size)
|
||||
assertTrue(model.credentials.find { it.credential == t1 } != null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adds one credential with code to empty`() {
|
||||
val d = "device"
|
||||
model.update(d, mapOf(totp() to code()))
|
||||
|
||||
assertEquals(1, model.credentials.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `does not add one credential with code to not initialized model`() {
|
||||
val d = "device"
|
||||
model.add(d, totp(), code())
|
||||
|
||||
assertEquals(0, model.credentials.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adds credential only to correct device`() {
|
||||
val d1 = "device1"
|
||||
val d2 = "device2"
|
||||
model.update(d1, mapOf(totp() to code()))
|
||||
|
||||
// cannot add to this model
|
||||
assertNull(model.add(d2, totp(), code()))
|
||||
|
||||
// can add to this model
|
||||
assertNotNull(model.add(d1, totp(), code()))
|
||||
|
||||
assertEquals(2, model.credentials.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renames only on correct device`() {
|
||||
val d1 = "device1"
|
||||
val d2 = "device2"
|
||||
val toRename = totp(d1, name = "oldName", issuer = "oldIssuer")
|
||||
val code1 = code()
|
||||
|
||||
model.update(d1, mapOf(toRename to code1))
|
||||
|
||||
val renamedForD2 = totp(d2, name = "newName", issuer = "newIssuer")
|
||||
assertNull(model.rename(d1, toRename, renamedForD2))
|
||||
|
||||
val renamedForD1 = totp(d1, name = "newName", issuer = "newIssuer")
|
||||
// trying to rename on wrong device
|
||||
assertNull(model.rename(d2, toRename, renamedForD2))
|
||||
|
||||
|
||||
// rename success
|
||||
val renamed = model.rename(d1, toRename, renamedForD1)
|
||||
assertNotNull(renamed)
|
||||
|
||||
// the name and issuer are correct
|
||||
assertEquals("newName", renamed?.accountName)
|
||||
assertEquals("newIssuer", renamed?.issuer)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `renames issuer`() {
|
||||
val d = "device1"
|
||||
val toRename = totp(d, name = "oldName", issuer = "oldIssuer")
|
||||
val code1 = code()
|
||||
|
||||
model.update(d, mapOf(toRename to code1))
|
||||
|
||||
val nullIssuer = totp(d, name = "newName", issuer = null)
|
||||
val renamed = model.rename(d, toRename, nullIssuer)
|
||||
|
||||
assertNull(renamed!!.issuer)
|
||||
|
||||
val nonNullIssuer = totp(d, name = "newName", issuer = "valueHere")
|
||||
val renamed2 = model.rename(d, renamed, nonNullIssuer)
|
||||
|
||||
assertNotNull(renamed2!!.issuer)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updates code`() {
|
||||
val d1 = "d1"
|
||||
val d2 = "d2"
|
||||
val totpD1 = totp(d1, name = "sameName", issuer = "sameIssuer")
|
||||
val totpD2 = totp(d2, name = "sameName", issuer = "sameIssuer")
|
||||
val code1 = code(value = "12345")
|
||||
val code2 = code(value = "00000")
|
||||
|
||||
model.update(d1, mapOf(totpD1 to code1))
|
||||
|
||||
// cant update on different device
|
||||
assertNull(model.updateCode(d2, totpD1, code()))
|
||||
|
||||
// cant update for credential from different device
|
||||
assertNull(model.updateCode(d1, totpD2, code()))
|
||||
|
||||
// updates correctly to new code
|
||||
val newCode = model.updateCode(d1, totpD1, code2)
|
||||
assertNotNull(newCode)
|
||||
assertEquals("00000", newCode!!.value!!)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removes data on reset`() {
|
||||
model.update("device", mapOf(totp() to code()))
|
||||
model.reset()
|
||||
|
||||
assertEquals("", model.session.deviceId)
|
||||
assertTrue(model.credentials.isEmpty())
|
||||
}
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
package com.yubico.authenticator.oath
|
||||
|
||||
object OathTestHelper {
|
||||
|
||||
// create a TOTP credential with default or custom parameters
|
||||
// if not specified, default values for deviceId, name and issuer will use a unique value
|
||||
// which is incremented on every call to this function
|
||||
fun totp(
|
||||
deviceId: String = nextDevice(),
|
||||
name: String = nextName(),
|
||||
issuer: String? = nextIssuer(),
|
||||
touchRequired: Boolean = false,
|
||||
period: Int = 30
|
||||
) = cred(deviceId, name, issuer, Model.OathType.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
|
||||
// which is incremented on every call to this function
|
||||
fun hotp(
|
||||
deviceId: String = nextDevice(),
|
||||
name: String = nextName(),
|
||||
issuer: String = nextIssuer(),
|
||||
touchRequired: Boolean = false,
|
||||
period: Int = 30
|
||||
) = cred(deviceId, name, issuer, Model.OathType.HOTP, touchRequired, period)
|
||||
|
||||
private fun cred(
|
||||
deviceId: String = nextDevice(),
|
||||
name: String = nextName(),
|
||||
issuer: String? = nextIssuer(),
|
||||
type: Model.OathType,
|
||||
touchRequired: Boolean = false,
|
||||
period: Int = 30
|
||||
) =
|
||||
Model.Credential(
|
||||
deviceId = deviceId,
|
||||
id = """otpauth://${type.name}/${name}?secret=aabbaabbaabbaabb&issuer=${issuer}""",
|
||||
oathType = type,
|
||||
period = period,
|
||||
issuer = issuer,
|
||||
accountName = name,
|
||||
touchRequired = touchRequired
|
||||
)
|
||||
// create a Code with default or custom parameters
|
||||
fun code(
|
||||
value: String = "111111",
|
||||
from: Long = 1000,
|
||||
to: Long = 2000
|
||||
) = Model.Code(value, from, to)
|
||||
|
||||
fun emptyCredentials() = emptyMap<Model.Credential, Model.Code>()
|
||||
|
||||
private var nameCounter = 0
|
||||
private fun nextName(): String {
|
||||
return "name${nameCounter++}"
|
||||
}
|
||||
|
||||
private var issuerCounter = 0
|
||||
private fun nextIssuer(): String {
|
||||
return "issuer${issuerCounter++}"
|
||||
}
|
||||
|
||||
private var deviceCounter = 0
|
||||
private fun nextDevice(): String {
|
||||
return "deviceId${deviceCounter++}"
|
||||
}
|
||||
}
|
@ -0,0 +1,117 @@
|
||||
package com.yubico.authenticator.oath
|
||||
|
||||
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.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class SerializationTest {
|
||||
|
||||
@Test
|
||||
fun `serialization settings`() {
|
||||
assertTrue(jsonSerializer.configuration.encodeDefaults)
|
||||
assertFalse(jsonSerializer.configuration.allowStructuredMapKeys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session json type`() {
|
||||
val s = Model.Session()
|
||||
|
||||
val jsonElement = jsonSerializer.encodeToJsonElement(s)
|
||||
assertTrue(jsonElement is JsonObject)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `session json property names`() {
|
||||
val s = Model.Session()
|
||||
|
||||
val jsonObject : JsonObject = jsonSerializer.encodeToJsonElement(s) as JsonObject
|
||||
assertTrue(jsonObject.containsKey("device_id"))
|
||||
assertTrue(jsonObject.containsKey("has_key"))
|
||||
assertTrue(jsonObject.containsKey("remembered"))
|
||||
assertTrue(jsonObject.containsKey("locked"))
|
||||
assertTrue(jsonObject.containsKey("keystore"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `credential json type`() {
|
||||
val c = totp()
|
||||
|
||||
val jsonElement = jsonSerializer.encodeToJsonElement(c)
|
||||
assertTrue(jsonElement is JsonObject)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `credential json property names`() {
|
||||
val c = totp()
|
||||
|
||||
val jsonObject : JsonObject = jsonSerializer.encodeToJsonElement(c) as JsonObject
|
||||
|
||||
assertTrue(jsonObject.containsKey("device_id"))
|
||||
assertTrue(jsonObject.containsKey("id"))
|
||||
assertTrue(jsonObject.containsKey("oath_type"))
|
||||
assertTrue(jsonObject.containsKey("period"))
|
||||
assertTrue(jsonObject.containsKey("issuer"))
|
||||
assertTrue(jsonObject.containsKey("name"))
|
||||
assertTrue(jsonObject.containsKey("touch_required"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `code json type`() {
|
||||
val c = code()
|
||||
|
||||
val jsonElement = jsonSerializer.encodeToJsonElement(c)
|
||||
assertTrue(jsonElement is JsonObject)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `code json property names`() {
|
||||
val c = code()
|
||||
|
||||
val jsonObject : JsonObject = jsonSerializer.encodeToJsonElement(c) as JsonObject
|
||||
|
||||
assertTrue(jsonObject.containsKey("value"))
|
||||
assertTrue(jsonObject.containsKey("valid_from"))
|
||||
assertTrue(jsonObject.containsKey("valid_to"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `code json content`() {
|
||||
val c = code(value = "001122", from = 1000, to = 2000)
|
||||
|
||||
val jsonObject : JsonObject = jsonSerializer.encodeToJsonElement(c) as JsonObject
|
||||
|
||||
assertEquals(JsonPrimitive(1000), jsonObject["valid_from"])
|
||||
assertEquals(JsonPrimitive(2000), jsonObject["valid_to"])
|
||||
assertEquals(JsonPrimitive("001122"), jsonObject["value"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `credentials json type`() {
|
||||
val l = listOf(
|
||||
Model.CredentialWithCode(totp(), code()), Model.CredentialWithCode(hotp(), code()),
|
||||
)
|
||||
|
||||
val jsonElement = jsonSerializer.encodeToJsonElement(l)
|
||||
assertTrue(jsonElement is JsonArray)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `credentials json size`() {
|
||||
val l1 = listOf<Model.CredentialWithCode>()
|
||||
val jsonElement1 = jsonSerializer.encodeToJsonElement(l1) as JsonArray
|
||||
assertEquals(0, jsonElement1.size)
|
||||
|
||||
val l2 = listOf(
|
||||
Model.CredentialWithCode(totp(), code()), Model.CredentialWithCode(hotp(), code()),
|
||||
)
|
||||
val jsonElement2 = jsonSerializer.encodeToJsonElement(l2) as JsonArray
|
||||
assertEquals(2, jsonElement2.size)
|
||||
}
|
||||
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.6.10'
|
||||
ext.kotlin_version = '1.6.20'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
@ -8,7 +8,7 @@ buildscript {
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.1.3'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath "org.jetbrains.kotlin:kotlin-serialization:1.6.10"
|
||||
classpath "org.jetbrains.kotlin:kotlin-serialization:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -14,11 +14,7 @@ class _StateProvider extends StateNotifier<OathState?> {
|
||||
|
||||
void setFromString(String input) {
|
||||
var resultJson = jsonDecode(input);
|
||||
state = OathState(resultJson['deviceId'],
|
||||
hasKey: resultJson['hasKey'],
|
||||
remembered: resultJson['remembered'],
|
||||
locked: resultJson['locked'],
|
||||
keystore: KeystoreState.unknown);
|
||||
state = OathState.fromJson(resultJson);
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,13 +29,10 @@ class _CredentialsProvider extends StateNotifier<List<OathPair>?> {
|
||||
void setFromString(String input) {
|
||||
var result = jsonDecode(input);
|
||||
|
||||
final List<OathPair> pairs = [];
|
||||
for (var e in result['entries']) {
|
||||
final credential = OathCredential.fromJson(e['credential']);
|
||||
final code = e['code'] == null ? null : OathCode.fromJson(e['code']);
|
||||
pairs.add(OathPair(credential, code));
|
||||
if (result is List) {
|
||||
state = result.map((e) => OathPair.fromJson(e)).toList();
|
||||
} else {
|
||||
state = [];
|
||||
}
|
||||
|
||||
state = pairs;
|
||||
}
|
||||
}
|
||||
|
@ -53,6 +53,9 @@ class OathCode with _$OathCode {
|
||||
@freezed
|
||||
class OathPair with _$OathPair {
|
||||
factory OathPair(OathCredential credential, OathCode? code) = _OathPair;
|
||||
|
||||
factory OathPair.fromJson(Map<String, dynamic> json) =>
|
||||
_$OathPairFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
|
@ -474,6 +474,10 @@ abstract class _OathCode implements OathCode {
|
||||
throw _privateConstructorUsedError;
|
||||
}
|
||||
|
||||
OathPair _$OathPairFromJson(Map<String, dynamic> json) {
|
||||
return _OathPair.fromJson(json);
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
class _$OathPairTearOff {
|
||||
const _$OathPairTearOff();
|
||||
@ -484,6 +488,10 @@ class _$OathPairTearOff {
|
||||
code,
|
||||
);
|
||||
}
|
||||
|
||||
OathPair fromJson(Map<String, Object?> json) {
|
||||
return OathPair.fromJson(json);
|
||||
}
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
@ -494,6 +502,7 @@ mixin _$OathPair {
|
||||
OathCredential get credential => throw _privateConstructorUsedError;
|
||||
OathCode? get code => throw _privateConstructorUsedError;
|
||||
|
||||
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
|
||||
@JsonKey(ignore: true)
|
||||
$OathPairCopyWith<OathPair> get copyWith =>
|
||||
throw _privateConstructorUsedError;
|
||||
@ -594,10 +603,13 @@ class __$OathPairCopyWithImpl<$Res> extends _$OathPairCopyWithImpl<$Res>
|
||||
}
|
||||
|
||||
/// @nodoc
|
||||
|
||||
@JsonSerializable()
|
||||
class _$_OathPair implements _OathPair {
|
||||
_$_OathPair(this.credential, this.code);
|
||||
|
||||
factory _$_OathPair.fromJson(Map<String, dynamic> json) =>
|
||||
_$$_OathPairFromJson(json);
|
||||
|
||||
@override
|
||||
final OathCredential credential;
|
||||
@override
|
||||
@ -628,11 +640,18 @@ class _$_OathPair implements _OathPair {
|
||||
@override
|
||||
_$OathPairCopyWith<_OathPair> get copyWith =>
|
||||
__$OathPairCopyWithImpl<_OathPair>(this, _$identity);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson() {
|
||||
return _$$_OathPairToJson(this);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class _OathPair implements OathPair {
|
||||
factory _OathPair(OathCredential credential, OathCode? code) = _$_OathPair;
|
||||
|
||||
factory _OathPair.fromJson(Map<String, dynamic> json) = _$_OathPair.fromJson;
|
||||
|
||||
@override
|
||||
OathCredential get credential;
|
||||
@override
|
||||
|
@ -46,6 +46,19 @@ Map<String, dynamic> _$$_OathCodeToJson(_$_OathCode instance) =>
|
||||
'valid_to': instance.validTo,
|
||||
};
|
||||
|
||||
_$_OathPair _$$_OathPairFromJson(Map<String, dynamic> json) => _$_OathPair(
|
||||
OathCredential.fromJson(json['credential'] as Map<String, dynamic>),
|
||||
json['code'] == null
|
||||
? null
|
||||
: OathCode.fromJson(json['code'] as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$$_OathPairToJson(_$_OathPair instance) =>
|
||||
<String, dynamic>{
|
||||
'credential': instance.credential,
|
||||
'code': instance.code,
|
||||
};
|
||||
|
||||
_$_OathState _$$_OathStateFromJson(Map<String, dynamic> json) => _$_OathState(
|
||||
json['device_id'] as String,
|
||||
hasKey: json['has_key'] as bool,
|
||||
|
Loading…
Reference in New Issue
Block a user