mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-11-26 10:33:15 +03:00
Merge PR #71.
This commit is contained in:
commit
e11ca5b531
@ -3,6 +3,7 @@ package com.yubico.authenticator.api
|
||||
import com.yubico.authenticator.MainViewModel
|
||||
import com.yubico.authenticator.api.Pigeon.OathApi
|
||||
import com.yubico.authenticator.api.Pigeon.Result
|
||||
import com.yubico.authenticator.api.Pigeon.UnlockResponse
|
||||
|
||||
class OathApiImpl(private val viewModel: MainViewModel) : OathApi {
|
||||
|
||||
@ -13,7 +14,7 @@ class OathApiImpl(private val viewModel: MainViewModel) : OathApi {
|
||||
override fun unlock(
|
||||
password: String,
|
||||
remember: Boolean,
|
||||
result: Result<Boolean>
|
||||
result: Result<UnlockResponse>
|
||||
) {
|
||||
viewModel.unlockOathSession(password, remember, result)
|
||||
}
|
||||
|
@ -22,6 +22,54 @@ import java.util.HashMap;
|
||||
@SuppressWarnings({"unused", "unchecked", "CodeBlock2Expr", "RedundantSuppression"})
|
||||
public class Pigeon {
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
public static class UnlockResponse {
|
||||
private @Nullable Boolean isUnlocked;
|
||||
public @Nullable Boolean getIsUnlocked() { return isUnlocked; }
|
||||
public void setIsUnlocked(@Nullable Boolean setterArg) {
|
||||
this.isUnlocked = setterArg;
|
||||
}
|
||||
|
||||
private @Nullable Boolean isRemembered;
|
||||
public @Nullable Boolean getIsRemembered() { return isRemembered; }
|
||||
public void setIsRemembered(@Nullable Boolean setterArg) {
|
||||
this.isRemembered = setterArg;
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
private @Nullable Boolean isUnlocked;
|
||||
public @NonNull Builder setIsUnlocked(@Nullable Boolean setterArg) {
|
||||
this.isUnlocked = setterArg;
|
||||
return this;
|
||||
}
|
||||
private @Nullable Boolean isRemembered;
|
||||
public @NonNull Builder setIsRemembered(@Nullable Boolean setterArg) {
|
||||
this.isRemembered = setterArg;
|
||||
return this;
|
||||
}
|
||||
public @NonNull UnlockResponse build() {
|
||||
UnlockResponse pigeonReturn = new UnlockResponse();
|
||||
pigeonReturn.setIsUnlocked(isUnlocked);
|
||||
pigeonReturn.setIsRemembered(isRemembered);
|
||||
return pigeonReturn;
|
||||
}
|
||||
}
|
||||
@NonNull Map<String, Object> toMap() {
|
||||
Map<String, Object> toMapResult = new HashMap<>();
|
||||
toMapResult.put("isUnlocked", isUnlocked);
|
||||
toMapResult.put("isRemembered", isRemembered);
|
||||
return toMapResult;
|
||||
}
|
||||
static @NonNull UnlockResponse fromMap(@NonNull Map<String, Object> map) {
|
||||
UnlockResponse pigeonResult = new UnlockResponse();
|
||||
Object isUnlocked = map.get("isUnlocked");
|
||||
pigeonResult.setIsUnlocked((Boolean)isUnlocked);
|
||||
Object isRemembered = map.get("isRemembered");
|
||||
pigeonResult.setIsRemembered((Boolean)isRemembered);
|
||||
return pigeonResult;
|
||||
}
|
||||
}
|
||||
|
||||
public interface Result<T> {
|
||||
void success(T result);
|
||||
void error(Throwable error);
|
||||
@ -29,12 +77,33 @@ public class Pigeon {
|
||||
private static class OathApiCodec extends StandardMessageCodec {
|
||||
public static final OathApiCodec INSTANCE = new OathApiCodec();
|
||||
private OathApiCodec() {}
|
||||
@Override
|
||||
protected Object readValueOfType(byte type, ByteBuffer buffer) {
|
||||
switch (type) {
|
||||
case (byte)128:
|
||||
return UnlockResponse.fromMap((Map<String, Object>) readValue(buffer));
|
||||
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
|
||||
}
|
||||
}
|
||||
@Override
|
||||
protected void writeValue(ByteArrayOutputStream stream, Object value) {
|
||||
if (value instanceof UnlockResponse) {
|
||||
stream.write(128);
|
||||
writeValue(stream, ((UnlockResponse) value).toMap());
|
||||
} else
|
||||
{
|
||||
super.writeValue(stream, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter.*/
|
||||
public interface OathApi {
|
||||
void reset(Result<Void> result);
|
||||
void unlock(@NonNull String password, @NonNull Boolean remember, Result<Boolean> result);
|
||||
void unlock(@NonNull String password, @NonNull Boolean remember, Result<UnlockResponse> result);
|
||||
void setPassword(@Nullable String currentPassword, @NonNull String newPassword, Result<Void> result);
|
||||
void unsetPassword(@NonNull String currentPassword, Result<Void> result);
|
||||
void forgetPassword(Result<Void> result);
|
||||
@ -96,8 +165,8 @@ public class Pigeon {
|
||||
if (rememberArg == null) {
|
||||
throw new NullPointerException("rememberArg unexpectedly null.");
|
||||
}
|
||||
Result<Boolean> resultCallback = new Result<Boolean>() {
|
||||
public void success(Boolean result) {
|
||||
Result<UnlockResponse> resultCallback = new Result<UnlockResponse>() {
|
||||
public void success(UnlockResponse result) {
|
||||
wrapped.put("result", result);
|
||||
reply.reply(wrapped);
|
||||
}
|
||||
|
@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2022, Yubico AB. All rights reserved.
|
||||
*
|
||||
* Redistribution and use in source and binary forms, with or without
|
||||
* modification, are permitted provided that the following conditions
|
||||
* are met:
|
||||
*
|
||||
* Redistributions of source code must retain the above copyright
|
||||
* notice, this list of conditions and the following disclaimer.
|
||||
*
|
||||
* Redistributions in binary form must reproduce the above copyright
|
||||
* notice, this list of conditions and the following
|
||||
* disclaimer in the documentation and/or other materials provided
|
||||
* with the distribution.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||
* CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
||||
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS
|
||||
* BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||
* EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
|
||||
* TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
|
||||
* TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
|
||||
* THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
|
||||
* SUCH DAMAGE.
|
||||
*/
|
||||
|
||||
package com.yubico.authenticator
|
||||
|
||||
import com.yubico.authenticator.keystore.KeyProvider
|
||||
import com.yubico.yubikit.oath.AccessKey
|
||||
|
||||
class KeyManager(private val permStore: KeyProvider, private val memStore: KeyProvider) {
|
||||
|
||||
/**
|
||||
* @return true if this deviceId is stored in permanent KeyStore
|
||||
*/
|
||||
fun isRemembered(deviceId: String) = permStore.hasKey(deviceId)
|
||||
|
||||
fun getKey(deviceId: String): AccessKey? {
|
||||
return if (permStore.hasKey(deviceId)) {
|
||||
permStore.getKey(deviceId)
|
||||
} else {
|
||||
memStore.getKey(deviceId)
|
||||
}
|
||||
}
|
||||
|
||||
fun addKey(deviceId: String, secret: ByteArray, remember: Boolean) {
|
||||
if (remember) {
|
||||
memStore.removeKey(deviceId)
|
||||
permStore.putKey(deviceId, secret)
|
||||
} else {
|
||||
permStore.removeKey(deviceId)
|
||||
memStore.putKey(deviceId, secret)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeKey(deviceId: String) {
|
||||
memStore.removeKey(deviceId)
|
||||
permStore.removeKey(deviceId)
|
||||
}
|
||||
|
||||
fun clearAll() {
|
||||
memStore.clearAll()
|
||||
permStore.clearAll()
|
||||
}
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
package com.yubico.authenticator
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.activity.viewModels
|
||||
|
@ -6,10 +6,9 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.yubico.authenticator.api.Pigeon
|
||||
import com.yubico.authenticator.data.device.toJson
|
||||
import com.yubico.authenticator.data.oath.calculateSteamCode
|
||||
import com.yubico.authenticator.data.oath.idAsString
|
||||
import com.yubico.authenticator.data.oath.isSteamCredential
|
||||
import com.yubico.authenticator.data.oath.toJson
|
||||
import com.yubico.authenticator.data.oath.*
|
||||
import com.yubico.authenticator.keystore.ClearingMemProvider
|
||||
import com.yubico.authenticator.keystore.KeyStoreProvider
|
||||
import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice
|
||||
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
|
||||
import com.yubico.yubikit.core.Logger
|
||||
@ -45,9 +44,11 @@ class MainViewModel : ViewModel() {
|
||||
val handleYubiKey: LiveData<Boolean> = _handleYubiKey
|
||||
|
||||
val yubiKeyDevice = MutableLiveData<YubiKeyDevice?>()
|
||||
private var isUsbKeyConnected: Boolean = false
|
||||
private var _isUsbKey: Boolean = false
|
||||
private var _previousNfcDeviceId = ""
|
||||
|
||||
private var _oathSessionPassword: CharArray? = null
|
||||
private val _memoryKeyProvider = ClearingMemProvider()
|
||||
private val _keyManager = KeyManager(KeyStoreProvider(), _memoryKeyProvider)
|
||||
|
||||
private var _operationContext = OperationContext.Oath
|
||||
|
||||
@ -100,7 +101,23 @@ class MainViewModel : ViewModel() {
|
||||
val oathSessionData = suspendCoroutine<String> {
|
||||
device.requestConnection(SmartCardConnection::class.java) { result ->
|
||||
val oathSession = OathSession(result.value)
|
||||
val isRemembered = false
|
||||
|
||||
val deviceId = oathSession.deviceId
|
||||
|
||||
_previousNfcDeviceId = if (device is NfcYubiKeyDevice) {
|
||||
if (deviceId != _previousNfcDeviceId) {
|
||||
// devices are different, clear access key for previous device
|
||||
_memoryKeyProvider.removeKey(_previousNfcDeviceId)
|
||||
}
|
||||
deviceId
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
// calling unlock session will remove invalid access keys
|
||||
tryToUnlockOathSession(oathSession)
|
||||
val isRemembered = _keyManager.isRemembered(oathSession.deviceId)
|
||||
|
||||
val oathSessionData = oathSession
|
||||
.toJson(isRemembered)
|
||||
.toString()
|
||||
@ -115,9 +132,7 @@ class MainViewModel : ViewModel() {
|
||||
val sendOathCodes = suspendCoroutine<String> {
|
||||
device.requestConnection(SmartCardConnection::class.java) { result ->
|
||||
val session = OathSession(result.value)
|
||||
|
||||
val isLocked = isOathSessionLocked(session)
|
||||
if (!isLocked) {
|
||||
if (tryToUnlockOathSession(session)) {
|
||||
val resultJson = calculateOathCodes(session)
|
||||
.toJson(session.deviceId)
|
||||
.toString()
|
||||
@ -136,7 +151,7 @@ class MainViewModel : ViewModel() {
|
||||
withContext(Dispatchers.IO) {
|
||||
pendingYubiKeyAction.value?.let {
|
||||
_pendingYubiKeyAction.postValue(null)
|
||||
if (!isUsbKeyConnected) {
|
||||
if (!_isUsbKey) {
|
||||
withContext(Dispatchers.Main) {
|
||||
requestHideDialog()
|
||||
}
|
||||
@ -147,7 +162,7 @@ class MainViewModel : ViewModel() {
|
||||
|
||||
suspend fun yubikeyAttached(device: YubiKeyDevice) {
|
||||
|
||||
isUsbKeyConnected = device is UsbYubiKeyDevice
|
||||
_isUsbKey = device is UsbYubiKeyDevice
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
if (pendingYubiKeyAction.value != null) {
|
||||
@ -176,13 +191,11 @@ class MainViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun yubikeyDetached() {
|
||||
|
||||
if (isUsbKeyConnected) {
|
||||
// forget the current password only for usb keys
|
||||
_oathSessionPassword = null
|
||||
if (_isUsbKey) {
|
||||
// clear keys from memory
|
||||
_memoryKeyProvider.clearAll()
|
||||
_fManagementApi.updateDeviceInfo("") {}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun onDialogClosed(result: Pigeon.Result<Void>) {
|
||||
@ -206,8 +219,7 @@ class MainViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
private fun <T> withUnlockedSession(session: OathSession, block: (OathSession) -> T): T {
|
||||
val isLocked = isOathSessionLocked(session)
|
||||
if (isLocked) {
|
||||
if (!tryToUnlockOathSession(session)) {
|
||||
throw Exception("Session is locked")
|
||||
}
|
||||
return block(session)
|
||||
@ -320,9 +332,9 @@ class MainViewModel : ViewModel() {
|
||||
throw Exception("Provided current password is invalid")
|
||||
}
|
||||
}
|
||||
val newPass = password.toCharArray()
|
||||
session.setPassword(newPass)
|
||||
_oathSessionPassword = newPass
|
||||
val accessKey = session.deriveAccessKey(password.toCharArray())
|
||||
session.setAccessKey(accessKey)
|
||||
_keyManager.addKey(session.deviceId, accessKey, false)
|
||||
Logger.d("Successfully set password")
|
||||
}
|
||||
} catch (cause: Throwable) {
|
||||
@ -341,7 +353,7 @@ class MainViewModel : ViewModel() {
|
||||
// test current password sent by the user
|
||||
if (session.unlock(currentPassword.toCharArray())) {
|
||||
session.deleteAccessKey()
|
||||
_oathSessionPassword = null
|
||||
_keyManager.removeKey(session.deviceId)
|
||||
Logger.d("Successfully unset password")
|
||||
result.success(null)
|
||||
return@useOathSession
|
||||
@ -369,7 +381,7 @@ class MainViewModel : ViewModel() {
|
||||
fun refreshOathCodes(result: Pigeon.Result<String>) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
if (!isUsbKeyConnected) {
|
||||
if (!_isUsbKey) {
|
||||
throw Exception("Cannot refresh for nfc key")
|
||||
}
|
||||
|
||||
@ -411,28 +423,33 @@ class MainViewModel : ViewModel() {
|
||||
fun unlockOathSession(
|
||||
password: String,
|
||||
remember: Boolean,
|
||||
result: Pigeon.Result<Boolean>
|
||||
result: Pigeon.Result<Pigeon.UnlockResponse>
|
||||
) {
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
var codes: String? = null
|
||||
useOathSession("Unlocking", true) {
|
||||
_oathSessionPassword = password.toCharArray()
|
||||
val isLocked = isOathSessionLocked(it)
|
||||
val accessKey = it.deriveAccessKey(password.toCharArray())
|
||||
_keyManager.addKey(it.deviceId, accessKey, remember)
|
||||
|
||||
if (!isLocked) {
|
||||
val response = Pigeon.UnlockResponse().apply {
|
||||
isUnlocked = tryToUnlockOathSession(it)
|
||||
isRemembered = _keyManager.isRemembered(it.deviceId)
|
||||
}
|
||||
if (response.isUnlocked == true) {
|
||||
codes = calculateOathCodes(it)
|
||||
.toJson(it.deviceId)
|
||||
.toString()
|
||||
}
|
||||
|
||||
result.success(!isLocked)
|
||||
result.success(response)
|
||||
}
|
||||
|
||||
codes?.let {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
_fOathApi.updateOathCredentials(it) {}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (cause: Throwable) {
|
||||
result.error(cause)
|
||||
@ -459,7 +476,7 @@ class MainViewModel : ViewModel() {
|
||||
queryUserToTap: Boolean,
|
||||
action: (OathSession) -> T
|
||||
) = suspendCoroutine<T> { outer ->
|
||||
if (queryUserToTap && !isUsbKeyConnected) {
|
||||
if (queryUserToTap && !_isUsbKey) {
|
||||
viewModelScope.launch(Dispatchers.Main) {
|
||||
requestShowDialog(title)
|
||||
}
|
||||
@ -484,30 +501,33 @@ class MainViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
/**
|
||||
* returns true if the session cannot be unlocked (either we don't have a password, or the password is incorrect
|
||||
* Tries to unlocks [OathSession] with [AccessKey] stored in [KeyManager]. On failure clears
|
||||
* relevant access keys from [KeyManager]
|
||||
*
|
||||
* returns false if we can unlock the session
|
||||
* @return true if we the session is not locked or it was successfully unlocked, false otherwise
|
||||
*/
|
||||
private fun isOathSessionLocked(session: OathSession): Boolean {
|
||||
private fun tryToUnlockOathSession(session: OathSession): Boolean {
|
||||
if (!session.isLocked) {
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
if (_oathSessionPassword == null) {
|
||||
return true // we have no password to unlock
|
||||
}
|
||||
val deviceId = session.deviceId
|
||||
val accessKey = _keyManager.getKey(deviceId)
|
||||
?: return false // we have no access key to unlock the session
|
||||
|
||||
val unlockSucceed = session.unlock(_oathSessionPassword!!)
|
||||
val unlockSucceed = session.unlock(accessKey)
|
||||
|
||||
if (unlockSucceed) {
|
||||
return false // we have everything to unlock the session
|
||||
return true
|
||||
}
|
||||
|
||||
_oathSessionPassword = null // reset the password as well as it did not work
|
||||
return true // the unlock did not work, session is locked
|
||||
_keyManager.removeKey(deviceId) // remove invalid access keys from [KeyManager]
|
||||
return false // the unlock did not work, session is locked
|
||||
}
|
||||
|
||||
fun forgetPassword(result: Pigeon.Result<Void>) {
|
||||
_keyManager.clearAll()
|
||||
Logger.d("Cleared all keys.")
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,53 @@
|
||||
package com.yubico.authenticator.keystore
|
||||
|
||||
import android.security.keystore.KeyProperties
|
||||
import com.yubico.yubikit.oath.AccessKey
|
||||
import java.util.*
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
class ClearingMemProvider : KeyProvider {
|
||||
private var current: Pair<String, Mac>? = null
|
||||
private var clearAllTask: TimerTask? = null
|
||||
|
||||
override fun hasKey(deviceId: String): Boolean = current?.first == deviceId
|
||||
|
||||
override fun getKey(deviceId: String): AccessKey? {
|
||||
|
||||
clearAllTask?.cancel()
|
||||
clearAllTask = Timer("clear-memory-keys", false)
|
||||
.schedule(5 * 60_000) {
|
||||
clearAll()
|
||||
}
|
||||
|
||||
current?.let {
|
||||
if (it.first == deviceId) {
|
||||
return MemStoredSigner(it.second)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
override fun putKey(deviceId: String, secret: ByteArray) {
|
||||
current = Pair(deviceId,
|
||||
Mac.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA1)
|
||||
.apply {
|
||||
init(SecretKeySpec(secret, algorithm))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun removeKey(deviceId: String) {
|
||||
current = null
|
||||
}
|
||||
|
||||
override fun clearAll() {
|
||||
current = null
|
||||
}
|
||||
|
||||
private inner class MemStoredSigner(val mac: Mac) : AccessKey {
|
||||
override fun calculateResponse(challenge: ByteArray?): ByteArray? = mac.doFinal(challenge)
|
||||
}
|
||||
}
|
@ -0,0 +1,11 @@
|
||||
package com.yubico.authenticator.keystore
|
||||
|
||||
import com.yubico.yubikit.oath.AccessKey
|
||||
|
||||
interface KeyProvider {
|
||||
fun hasKey(deviceId: String): Boolean
|
||||
fun getKey(deviceId: String): AccessKey?
|
||||
fun putKey(deviceId: String, secret: ByteArray)
|
||||
fun removeKey(deviceId: String)
|
||||
fun clearAll()
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
package com.yubico.authenticator.keystore
|
||||
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.security.keystore.KeyProtection
|
||||
import com.yubico.yubikit.oath.AccessKey
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
class KeyStoreProvider : KeyProvider {
|
||||
private val keystore = KeyStore.getInstance("AndroidKeyStore")
|
||||
|
||||
init {
|
||||
keystore.load(null)
|
||||
}
|
||||
|
||||
override fun hasKey(deviceId: String): Boolean = keystore.containsAlias(deviceId)
|
||||
|
||||
override fun getKey(deviceId: String): AccessKey? =
|
||||
if (hasKey(deviceId)) {
|
||||
KeyStoreStoredSigner(deviceId)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
override fun putKey(deviceId: String, secret: ByteArray) {
|
||||
keystore.setEntry(
|
||||
deviceId,
|
||||
KeyStore.SecretKeyEntry(
|
||||
SecretKeySpec(secret, KeyProperties.KEY_ALGORITHM_HMAC_SHA1)
|
||||
),
|
||||
KeyProtection.Builder(KeyProperties.PURPOSE_SIGN).build()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
override fun removeKey(deviceId: String) {
|
||||
keystore.deleteEntry(deviceId)
|
||||
}
|
||||
|
||||
override fun clearAll() {
|
||||
keystore.aliases().asSequence().forEach { keystore.deleteEntry(it) }
|
||||
}
|
||||
|
||||
private inner class KeyStoreStoredSigner(val deviceId: String) :
|
||||
AccessKey {
|
||||
val mac: Mac = Mac.getInstance(KeyProperties.KEY_ALGORITHM_HMAC_SHA1).apply {
|
||||
init(keystore.getKey(deviceId, null))
|
||||
}
|
||||
|
||||
override fun calculateResponse(challenge: ByteArray?): ByteArray? = mac.doFinal(challenge)
|
||||
}
|
||||
}
|
@ -0,0 +1,129 @@
|
||||
package com.yubico.authenticator
|
||||
|
||||
import com.yubico.authenticator.keystore.KeyProvider
|
||||
import com.yubico.yubikit.oath.AccessKey
|
||||
import org.junit.Assert
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Implementation of [StoredSigner] which returns [secret] for any challenge.
|
||||
*/
|
||||
class MockStoredSigner(private val secret: ByteArray) : AccessKey {
|
||||
override fun calculateResponse(challenge: ByteArray): ByteArray = secret
|
||||
}
|
||||
|
||||
/**
|
||||
* Implementation of [KeyProvider] backed by a [Map]
|
||||
*/
|
||||
class MockKeyProvider : KeyProvider {
|
||||
private val map: MutableMap<String, AccessKey> = mutableMapOf()
|
||||
|
||||
override fun hasKey(deviceId: String): Boolean = map[deviceId] != null
|
||||
|
||||
override fun getKey(deviceId: String): AccessKey? = map[deviceId]
|
||||
|
||||
override fun putKey(deviceId: String, secret: ByteArray) {
|
||||
map[deviceId] = MockStoredSigner(secret)
|
||||
}
|
||||
|
||||
override fun removeKey(deviceId: String) {
|
||||
map.remove(deviceId)
|
||||
}
|
||||
|
||||
override fun clearAll() {
|
||||
map.clear()
|
||||
}
|
||||
}
|
||||
|
||||
class KeyManagerTest {
|
||||
|
||||
private val device1Id = "d1"
|
||||
private val device2Id = "d2"
|
||||
private val secret1 = "secret".toByteArray()
|
||||
private val secret2 = "secret2".toByteArray()
|
||||
|
||||
private lateinit var permKeyProvider: KeyProvider
|
||||
private lateinit var memKeyProvider: KeyProvider
|
||||
private lateinit var keyManager: KeyManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
permKeyProvider = MockKeyProvider()
|
||||
memKeyProvider = MockKeyProvider()
|
||||
|
||||
keyManager = KeyManager(permKeyProvider, memKeyProvider)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adds secret to memory key provider`() {
|
||||
keyManager.addKey(device1Id, secret1, false)
|
||||
Assert.assertFalse(keyManager.isRemembered(device1Id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `adds secret to permanent key provider`() {
|
||||
keyManager.addKey(device1Id, secret1, true)
|
||||
Assert.assertTrue(keyManager.isRemembered(device1Id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns added key`() {
|
||||
keyManager.addKey(device1Id, secret1, true)
|
||||
val key1 = keyManager.getKey(device1Id)
|
||||
Assert.assertNotNull(key1)
|
||||
Assert.assertEquals(secret1, key1!!.calculateResponse(byteArrayOf()))
|
||||
|
||||
keyManager.addKey(device2Id, secret2, false)
|
||||
val key2 = keyManager.getKey(device2Id)
|
||||
Assert.assertNotNull(key2)
|
||||
Assert.assertEquals(secret2, key2!!.calculateResponse(byteArrayOf()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `associates keys with correct devices`() {
|
||||
keyManager.addKey(device1Id, secret1, true)
|
||||
|
||||
Assert.assertNotNull(keyManager.getKey(device1Id))
|
||||
Assert.assertNull(keyManager.getKey(device2Id))
|
||||
|
||||
keyManager.addKey(device2Id, secret2, false)
|
||||
Assert.assertNotNull(keyManager.getKey(device2Id))
|
||||
Assert.assertNotNull(keyManager.getKey(device1Id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clears keys for device`() {
|
||||
keyManager.addKey(device1Id, secret1, true)
|
||||
keyManager.addKey(device2Id, secret2, true)
|
||||
|
||||
keyManager.removeKey(device1Id)
|
||||
|
||||
Assert.assertNotNull(keyManager.getKey(device2Id))
|
||||
Assert.assertNull(keyManager.getKey(device1Id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clears all keys`() {
|
||||
keyManager.addKey(device1Id, secret1, true)
|
||||
keyManager.addKey(device2Id, secret2, false)
|
||||
|
||||
keyManager.clearAll()
|
||||
|
||||
Assert.assertNull(keyManager.getKey(device1Id))
|
||||
Assert.assertNull(keyManager.getKey(device2Id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `can overwrite stored key`() {
|
||||
keyManager.addKey(device1Id, secret1, true)
|
||||
keyManager.addKey(device1Id, secret2, true)
|
||||
|
||||
val key1 = keyManager.getKey(device1Id)
|
||||
Assert.assertEquals(secret2, key1!!.calculateResponse(byteArrayOf()))
|
||||
|
||||
keyManager.addKey(device1Id, secret1, true)
|
||||
val key2 = keyManager.getKey(device1Id)
|
||||
Assert.assertEquals(secret1, key2!!.calculateResponse(byteArrayOf()))
|
||||
}
|
||||
}
|
@ -8,8 +8,54 @@ import 'dart:typed_data' show Uint8List, Int32List, Int64List, Float64List;
|
||||
import 'package:flutter/foundation.dart' show WriteBuffer, ReadBuffer;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class UnlockResponse {
|
||||
UnlockResponse({
|
||||
this.isUnlocked,
|
||||
this.isRemembered,
|
||||
});
|
||||
|
||||
bool? isUnlocked;
|
||||
bool? isRemembered;
|
||||
|
||||
Object encode() {
|
||||
final Map<Object?, Object?> pigeonMap = <Object?, Object?>{};
|
||||
pigeonMap['isUnlocked'] = isUnlocked;
|
||||
pigeonMap['isRemembered'] = isRemembered;
|
||||
return pigeonMap;
|
||||
}
|
||||
|
||||
static UnlockResponse decode(Object message) {
|
||||
final Map<Object?, Object?> pigeonMap = message as Map<Object?, Object?>;
|
||||
return UnlockResponse(
|
||||
isUnlocked: pigeonMap['isUnlocked'] as bool?,
|
||||
isRemembered: pigeonMap['isRemembered'] as bool?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OathApiCodec extends StandardMessageCodec {
|
||||
const _OathApiCodec();
|
||||
@override
|
||||
void writeValue(WriteBuffer buffer, Object? value) {
|
||||
if (value is UnlockResponse) {
|
||||
buffer.putUint8(128);
|
||||
writeValue(buffer, value.encode());
|
||||
} else
|
||||
{
|
||||
super.writeValue(buffer, value);
|
||||
}
|
||||
}
|
||||
@override
|
||||
Object? readValueOfType(int type, ReadBuffer buffer) {
|
||||
switch (type) {
|
||||
case 128:
|
||||
return UnlockResponse.decode(readValue(buffer)!);
|
||||
|
||||
default:
|
||||
return super.readValueOfType(type, buffer);
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class OathApi {
|
||||
@ -44,7 +90,7 @@ class OathApi {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> unlock(String arg_password, bool arg_remember) async {
|
||||
Future<UnlockResponse> unlock(String arg_password, bool arg_remember) async {
|
||||
final BasicMessageChannel<Object?> channel = BasicMessageChannel<Object?>(
|
||||
'dev.flutter.pigeon.OathApi.unlock', codec, binaryMessenger: _binaryMessenger);
|
||||
final Map<Object?, Object?>? replyMap =
|
||||
@ -67,7 +113,7 @@ class OathApi {
|
||||
message: 'Host platform returned null value for non-null return value.',
|
||||
);
|
||||
} else {
|
||||
return (replyMap['result'] as bool?)!;
|
||||
return (replyMap['result'] as UnlockResponse?)!;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -48,13 +48,16 @@ class _AndroidOathStateNotifier extends OathStateNotifier {
|
||||
Future<Pair<bool, bool>> unlock(String password,
|
||||
{bool remember = false}) async {
|
||||
try {
|
||||
final unlockSuccess = await _api.unlock(password, remember);
|
||||
final unlockResponse = await _api.unlock(password, remember);
|
||||
|
||||
if (unlockSuccess) {
|
||||
final unlocked = unlockResponse.isUnlocked == true;
|
||||
final remembered = unlockResponse.isRemembered == true;
|
||||
|
||||
if (unlocked) {
|
||||
_log.config('applet unlocked');
|
||||
setData(state.value!.copyWith(locked: false));
|
||||
}
|
||||
return Pair(unlockSuccess, false); // TODO: provide correct second param
|
||||
return Pair(unlocked, remembered);
|
||||
} on PlatformException catch (e) {
|
||||
_log.config('Calling unlock failed with exception: $e');
|
||||
return Pair(false, false);
|
||||
|
@ -1,12 +1,17 @@
|
||||
import 'package:pigeon/pigeon.dart';
|
||||
|
||||
class UnlockResponse {
|
||||
bool? isUnlocked;
|
||||
bool? isRemembered;
|
||||
}
|
||||
|
||||
@HostApi()
|
||||
abstract class OathApi {
|
||||
@async
|
||||
void reset();
|
||||
|
||||
@async
|
||||
bool unlock(String password, bool remember);
|
||||
UnlockResponse unlock(String password, bool remember);
|
||||
|
||||
@async
|
||||
void setPassword(String? currentPassword, String newPassword);
|
||||
|
Loading…
Reference in New Issue
Block a user