This commit is contained in:
Adam Velebil 2022-05-06 15:06:37 +02:00
commit a9c94e13ef
No known key found for this signature in database
GPG Key ID: AC6D6B9D715FC084
14 changed files with 876 additions and 93 deletions

View File

@ -13,20 +13,22 @@ if (flutterRoot == null) {
def flutterVersionCode = localProperties.getProperty('flutter.versionCode') def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) { if (flutterVersionCode == null) {
//noinspection GroovyUnusedAssignment
flutterVersionCode = '1' flutterVersionCode = '1'
} }
def flutterVersionName = localProperties.getProperty('flutter.versionName') def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) { if (flutterVersionName == null) {
//noinspection GroovyUnusedAssignment
flutterVersionName = '1.0' flutterVersionName = '1.0'
} }
def authenticatorVersionCode = 59900 def authenticatorVersionCode = 59900
def authenticatorVersionName = "6.0.0-alpha.2" def authenticatorVersionName = '6.0.0-alpha.2'
apply plugin: 'com.android.application' apply plugin: 'com.android.application'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: "org.jetbrains.kotlin.plugin.serialization" apply plugin: 'kotlinx-serialization'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
android { android {
@ -80,11 +82,12 @@ dependencies {
api "com.yubico.yubikit:oath:$project.yubiKitVersion" api "com.yubico.yubikit:oath:$project.yubiKitVersion"
api "com.yubico.yubikit:support:$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' implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
// Lifecycle // Lifecycle
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1" implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1'
implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.fragment:fragment-ktx:1.4.1' implementation 'androidx.fragment:fragment-ktx:1.4.1'

View File

@ -5,4 +5,45 @@
# For more details, see # For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html # http://developer.android.com/guide/developing/tools/proguard.html
-dontwarn edu.umd.cs.findbugs.annotations.SuppressFBWarnings -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;
#}

View File

@ -2,56 +2,36 @@ package com.yubico.authenticator.oath
import com.yubico.yubikit.oath.Code import com.yubico.yubikit.oath.Code
import com.yubico.yubikit.oath.Credential import com.yubico.yubikit.oath.Credential
import com.yubico.yubikit.oath.OathSession import com.yubico.yubikit.oath.OathType
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
fun OathSession.toJson(remembered: Boolean) = JsonObject( fun ByteArray.asString() = joinToString(
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(
separator = "" separator = ""
) { b -> "%02x".format(b) } ) { b -> "%02x".format(b) }
fun Credential.toJson(deviceId: String) = JsonObject( // convert yubikit types to Model types
mapOf( fun Credential.model(deviceId: String) = Model.Credential(
"id" to JsonPrimitive(idAsString()), deviceId = deviceId,
"device_id" to JsonPrimitive(deviceId), id = id.asString(),
"issuer" to JsonPrimitive(issuer), oathType = when (oathType) {
"name" to JsonPrimitive(accountName), OathType.HOTP -> Model.OathType.HOTP
"oath_type" to JsonPrimitive(oathType.value), else -> Model.OathType.TOTP
"period" to JsonPrimitive(period), },
"touch_required" to JsonPrimitive(isTouchRequired), period = period,
) issuer = issuer,
accountName = accountName,
touchRequired = isTouchRequired
) )
fun Map<Credential, Code?>.toJson(deviceId: String) = JsonObject( fun Code.model() = Model.Code(
mapOf( value,
"entries" to JsonArray( validFrom / 1000,
map { it.toPair().toJson(deviceId) } validUntil / 1000
)
fun Map<Credential, Code?>.model(deviceId: String): Map<Model.Credential, Model.Code?> =
map { (credential, code) ->
Pair(
credential.model(deviceId),
code?.model()
) )
) }.toMap()
)
fun Pair<Credential, Code?>.toJson(deviceId: String) = JsonObject(
mapOf(
"credential" to first.toJson(deviceId),
"code" to (second?.toJson() ?: JsonNull)
)
)

View File

@ -0,0 +1,8 @@
package com.yubico.authenticator.oath
import kotlinx.serialization.json.Json
val jsonSerializer = Json {
// creates properties for default values
encodeDefaults = true
}

View File

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

View File

@ -18,6 +18,7 @@ import com.yubico.yubikit.oath.*
import com.yubico.yubikit.support.DeviceUtil import com.yubico.yubikit.support.DeviceUtil
import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.BinaryMessenger
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.serialization.encodeToString
import java.net.URI import java.net.URI
import java.util.concurrent.Executors import java.util.concurrent.Executors
import kotlin.coroutines.resume import kotlin.coroutines.resume
@ -45,6 +46,8 @@ class OathManager(
private val _pendingYubiKeyAction = MutableLiveData<YubiKeyAction?>() private val _pendingYubiKeyAction = MutableLiveData<YubiKeyAction?>()
private val pendingYubiKeyAction: LiveData<YubiKeyAction?> = _pendingYubiKeyAction private val pendingYubiKeyAction: LiveData<YubiKeyAction?> = _pendingYubiKeyAction
private val _model = Model()
init { init {
OathApi.setup(messenger, this) OathApi.setup(messenger, this)
@ -112,6 +115,7 @@ class OathManager(
_memoryKeyProvider.clearAll() _memoryKeyProvider.clearAll()
_pendingYubiKeyAction.postValue(null) _pendingYubiKeyAction.postValue(null)
_fManagementApi.updateDeviceInfo("") {} _fManagementApi.updateDeviceInfo("") {}
_model.reset()
} }
} }
@ -147,9 +151,8 @@ class OathManager(
isRemembered = _keyManager.isRemembered(it.deviceId) isRemembered = _keyManager.isRemembered(it.deviceId)
} }
if (response.isUnlocked == true) { if (response.isUnlocked == true) {
codes = calculateOathCodes(it) _model.update(it.deviceId, calculateOathCodes(it).model(it.deviceId))
.toJson(it.deviceId) codes = jsonSerializer.encodeToString(_model.credentials)
.toString()
} }
returnSuccess(result, response) returnSuccess(result, response)
} }
@ -243,11 +246,19 @@ class OathManager(
calculateCode(session, credential, System.currentTimeMillis()) calculateCode(session, credential, System.currentTimeMillis())
} else null } else null
val jsonResult = Pair<Credential, Code?>(credential, code) val addedCred = _model.add(
.toJson(session.deviceId) session.deviceId,
.toString() 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) { } catch (cause: Throwable) {
@ -263,12 +274,21 @@ class OathManager(
withUnlockedSession(session) { withUnlockedSession(session) {
val credential = getOathCredential(session, uri) val credential = getOathCredential(session, uri)
val jsonResult = val renamedCredential = _model.rename(
session.renameCredential(credential, name, issuer) it.deviceId,
.toJson(session.deviceId) credential.model(it.deviceId),
.toString() 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) { } catch (cause: Throwable) {
@ -298,9 +318,12 @@ class OathManager(
useOathSession("Refresh codes", false) { useOathSession("Refresh codes", false) {
withUnlockedSession(it) { session -> withUnlockedSession(it) { session ->
val resultJson = calculateOathCodes(session)
.toJson(session.deviceId) _model.update(
.toString() session.deviceId,
calculateOathCodes(session).model(session.deviceId)
)
val resultJson = jsonSerializer.encodeToString(_model.credentials)
returnSuccess(result, resultJson) returnSuccess(result, resultJson)
} }
} }
@ -318,12 +341,20 @@ class OathManager(
val credential = getOathCredential(session, uri) val credential = getOathCredential(session, uri)
val resultJson = val code = _model.updateCode(
calculateCode(session, credential, System.currentTimeMillis()) session.deviceId,
.toJson() credential.model(session.deviceId),
.toString() 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) { } catch (cause: Throwable) {
@ -396,9 +427,14 @@ class OathManager(
tryToUnlockOathSession(oathSession) tryToUnlockOathSession(oathSession)
val isRemembered = _keyManager.isRemembered(oathSession.deviceId) val isRemembered = _keyManager.isRemembered(oathSession.deviceId)
val oathSessionData = oathSession _model.session = Model.Session(
.toJson(isRemembered) oathSession.deviceId,
.toString() oathSession.isAccessKeySet,
isRemembered,
oathSession.isLocked
)
val oathSessionData = jsonSerializer.encodeToString(_model.session)
it.resume(oathSessionData) it.resume(oathSessionData)
} }
} }
@ -411,9 +447,11 @@ class OathManager(
device.requestConnection(SmartCardConnection::class.java) { result -> device.requestConnection(SmartCardConnection::class.java) { result ->
val session = OathSession(result.value) val session = OathSession(result.value)
if (tryToUnlockOathSession(session)) { if (tryToUnlockOathSession(session)) {
val resultJson = calculateOathCodes(session) _model.update(
.toJson(session.deviceId) session.deviceId,
.toString() calculateOathCodes(session).model(session.deviceId)
)
val resultJson = jsonSerializer.encodeToString(_model.credentials)
it.resume(resultJson) it.resume(resultJson)
} }
} }
@ -503,7 +541,7 @@ class OathManager(
private fun getOathCredential(oathSession: OathSession, credentialId: String) = private fun getOathCredential(oathSession: OathSession, credentialId: String) =
oathSession.credentials.firstOrNull { credential -> oathSession.credentials.firstOrNull { credential ->
(credential != null) && credential.idAsString() == credentialId (credential != null) && credential.id.asString() == credentialId
} ?: throw Exception("Failed to find account to delete") } ?: throw Exception("Failed to find account to delete")

View File

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

View File

@ -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++}"
}
}

View File

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

View File

@ -1,5 +1,5 @@
buildscript { buildscript {
ext.kotlin_version = '1.6.10' ext.kotlin_version = '1.6.20'
repositories { repositories {
google() google()
mavenCentral() mavenCentral()
@ -8,7 +8,7 @@ buildscript {
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:7.1.3' classpath 'com.android.tools.build:gradle:7.1.3'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" 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"
} }
} }

View File

@ -14,11 +14,7 @@ class _StateProvider extends StateNotifier<OathState?> {
void setFromString(String input) { void setFromString(String input) {
var resultJson = jsonDecode(input); var resultJson = jsonDecode(input);
state = OathState(resultJson['deviceId'], state = OathState.fromJson(resultJson);
hasKey: resultJson['hasKey'],
remembered: resultJson['remembered'],
locked: resultJson['locked'],
keystore: KeystoreState.unknown);
} }
} }
@ -33,13 +29,10 @@ class _CredentialsProvider extends StateNotifier<List<OathPair>?> {
void setFromString(String input) { void setFromString(String input) {
var result = jsonDecode(input); var result = jsonDecode(input);
final List<OathPair> pairs = []; if (result is List) {
for (var e in result['entries']) { state = result.map((e) => OathPair.fromJson(e)).toList();
final credential = OathCredential.fromJson(e['credential']); } else {
final code = e['code'] == null ? null : OathCode.fromJson(e['code']); state = [];
pairs.add(OathPair(credential, code));
} }
state = pairs;
} }
} }

View File

@ -53,6 +53,9 @@ class OathCode with _$OathCode {
@freezed @freezed
class OathPair with _$OathPair { class OathPair with _$OathPair {
factory OathPair(OathCredential credential, OathCode? code) = _OathPair; factory OathPair(OathCredential credential, OathCode? code) = _OathPair;
factory OathPair.fromJson(Map<String, dynamic> json) =>
_$OathPairFromJson(json);
} }
@freezed @freezed

View File

@ -474,6 +474,10 @@ abstract class _OathCode implements OathCode {
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
} }
OathPair _$OathPairFromJson(Map<String, dynamic> json) {
return _OathPair.fromJson(json);
}
/// @nodoc /// @nodoc
class _$OathPairTearOff { class _$OathPairTearOff {
const _$OathPairTearOff(); const _$OathPairTearOff();
@ -484,6 +488,10 @@ class _$OathPairTearOff {
code, code,
); );
} }
OathPair fromJson(Map<String, Object?> json) {
return OathPair.fromJson(json);
}
} }
/// @nodoc /// @nodoc
@ -494,6 +502,7 @@ mixin _$OathPair {
OathCredential get credential => throw _privateConstructorUsedError; OathCredential get credential => throw _privateConstructorUsedError;
OathCode? get code => throw _privateConstructorUsedError; OathCode? get code => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true) @JsonKey(ignore: true)
$OathPairCopyWith<OathPair> get copyWith => $OathPairCopyWith<OathPair> get copyWith =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
@ -594,10 +603,13 @@ class __$OathPairCopyWithImpl<$Res> extends _$OathPairCopyWithImpl<$Res>
} }
/// @nodoc /// @nodoc
@JsonSerializable()
class _$_OathPair implements _OathPair { class _$_OathPair implements _OathPair {
_$_OathPair(this.credential, this.code); _$_OathPair(this.credential, this.code);
factory _$_OathPair.fromJson(Map<String, dynamic> json) =>
_$$_OathPairFromJson(json);
@override @override
final OathCredential credential; final OathCredential credential;
@override @override
@ -628,11 +640,18 @@ class _$_OathPair implements _OathPair {
@override @override
_$OathPairCopyWith<_OathPair> get copyWith => _$OathPairCopyWith<_OathPair> get copyWith =>
__$OathPairCopyWithImpl<_OathPair>(this, _$identity); __$OathPairCopyWithImpl<_OathPair>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$_OathPairToJson(this);
}
} }
abstract class _OathPair implements OathPair { abstract class _OathPair implements OathPair {
factory _OathPair(OathCredential credential, OathCode? code) = _$_OathPair; factory _OathPair(OathCredential credential, OathCode? code) = _$_OathPair;
factory _OathPair.fromJson(Map<String, dynamic> json) = _$_OathPair.fromJson;
@override @override
OathCredential get credential; OathCredential get credential;
@override @override

View File

@ -46,6 +46,19 @@ Map<String, dynamic> _$$_OathCodeToJson(_$_OathCode instance) =>
'valid_to': instance.validTo, '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( _$_OathState _$$_OathStateFromJson(Map<String, dynamic> json) => _$_OathState(
json['device_id'] as String, json['device_id'] as String,
hasKey: json['has_key'] as bool, hasKey: json['has_key'] as bool,