mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-11-23 00:57:26 +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.MainViewModel
|
||||||
import com.yubico.authenticator.api.Pigeon.OathApi
|
import com.yubico.authenticator.api.Pigeon.OathApi
|
||||||
import com.yubico.authenticator.api.Pigeon.Result
|
import com.yubico.authenticator.api.Pigeon.Result
|
||||||
|
import com.yubico.authenticator.api.Pigeon.UnlockResponse
|
||||||
|
|
||||||
class OathApiImpl(private val viewModel: MainViewModel) : OathApi {
|
class OathApiImpl(private val viewModel: MainViewModel) : OathApi {
|
||||||
|
|
||||||
@ -13,7 +14,7 @@ class OathApiImpl(private val viewModel: MainViewModel) : OathApi {
|
|||||||
override fun unlock(
|
override fun unlock(
|
||||||
password: String,
|
password: String,
|
||||||
remember: Boolean,
|
remember: Boolean,
|
||||||
result: Result<Boolean>
|
result: Result<UnlockResponse>
|
||||||
) {
|
) {
|
||||||
viewModel.unlockOathSession(password, remember, result)
|
viewModel.unlockOathSession(password, remember, result)
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,54 @@ import java.util.HashMap;
|
|||||||
@SuppressWarnings({"unused", "unchecked", "CodeBlock2Expr", "RedundantSuppression"})
|
@SuppressWarnings({"unused", "unchecked", "CodeBlock2Expr", "RedundantSuppression"})
|
||||||
public class Pigeon {
|
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> {
|
public interface Result<T> {
|
||||||
void success(T result);
|
void success(T result);
|
||||||
void error(Throwable error);
|
void error(Throwable error);
|
||||||
@ -29,12 +77,33 @@ public class Pigeon {
|
|||||||
private static class OathApiCodec extends StandardMessageCodec {
|
private static class OathApiCodec extends StandardMessageCodec {
|
||||||
public static final OathApiCodec INSTANCE = new OathApiCodec();
|
public static final OathApiCodec INSTANCE = new OathApiCodec();
|
||||||
private 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.*/
|
/** Generated interface from Pigeon that represents a handler of messages from Flutter.*/
|
||||||
public interface OathApi {
|
public interface OathApi {
|
||||||
void reset(Result<Void> result);
|
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 setPassword(@Nullable String currentPassword, @NonNull String newPassword, Result<Void> result);
|
||||||
void unsetPassword(@NonNull String currentPassword, Result<Void> result);
|
void unsetPassword(@NonNull String currentPassword, Result<Void> result);
|
||||||
void forgetPassword(Result<Void> result);
|
void forgetPassword(Result<Void> result);
|
||||||
@ -96,8 +165,8 @@ public class Pigeon {
|
|||||||
if (rememberArg == null) {
|
if (rememberArg == null) {
|
||||||
throw new NullPointerException("rememberArg unexpectedly null.");
|
throw new NullPointerException("rememberArg unexpectedly null.");
|
||||||
}
|
}
|
||||||
Result<Boolean> resultCallback = new Result<Boolean>() {
|
Result<UnlockResponse> resultCallback = new Result<UnlockResponse>() {
|
||||||
public void success(Boolean result) {
|
public void success(UnlockResponse result) {
|
||||||
wrapped.put("result", result);
|
wrapped.put("result", result);
|
||||||
reply.reply(wrapped);
|
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
|
package com.yubico.authenticator
|
||||||
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
|
@ -6,10 +6,9 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.yubico.authenticator.api.Pigeon
|
import com.yubico.authenticator.api.Pigeon
|
||||||
import com.yubico.authenticator.data.device.toJson
|
import com.yubico.authenticator.data.device.toJson
|
||||||
import com.yubico.authenticator.data.oath.calculateSteamCode
|
import com.yubico.authenticator.data.oath.*
|
||||||
import com.yubico.authenticator.data.oath.idAsString
|
import com.yubico.authenticator.keystore.ClearingMemProvider
|
||||||
import com.yubico.authenticator.data.oath.isSteamCredential
|
import com.yubico.authenticator.keystore.KeyStoreProvider
|
||||||
import com.yubico.authenticator.data.oath.toJson
|
|
||||||
import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice
|
import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice
|
||||||
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
|
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
|
||||||
import com.yubico.yubikit.core.Logger
|
import com.yubico.yubikit.core.Logger
|
||||||
@ -45,9 +44,11 @@ class MainViewModel : ViewModel() {
|
|||||||
val handleYubiKey: LiveData<Boolean> = _handleYubiKey
|
val handleYubiKey: LiveData<Boolean> = _handleYubiKey
|
||||||
|
|
||||||
val yubiKeyDevice = MutableLiveData<YubiKeyDevice?>()
|
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
|
private var _operationContext = OperationContext.Oath
|
||||||
|
|
||||||
@ -100,7 +101,23 @@ class MainViewModel : ViewModel() {
|
|||||||
val oathSessionData = suspendCoroutine<String> {
|
val oathSessionData = suspendCoroutine<String> {
|
||||||
device.requestConnection(SmartCardConnection::class.java) { result ->
|
device.requestConnection(SmartCardConnection::class.java) { result ->
|
||||||
val oathSession = OathSession(result.value)
|
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
|
val oathSessionData = oathSession
|
||||||
.toJson(isRemembered)
|
.toJson(isRemembered)
|
||||||
.toString()
|
.toString()
|
||||||
@ -115,9 +132,7 @@ class MainViewModel : ViewModel() {
|
|||||||
val sendOathCodes = suspendCoroutine<String> {
|
val sendOathCodes = suspendCoroutine<String> {
|
||||||
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)) {
|
||||||
val isLocked = isOathSessionLocked(session)
|
|
||||||
if (!isLocked) {
|
|
||||||
val resultJson = calculateOathCodes(session)
|
val resultJson = calculateOathCodes(session)
|
||||||
.toJson(session.deviceId)
|
.toJson(session.deviceId)
|
||||||
.toString()
|
.toString()
|
||||||
@ -136,7 +151,7 @@ class MainViewModel : ViewModel() {
|
|||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
pendingYubiKeyAction.value?.let {
|
pendingYubiKeyAction.value?.let {
|
||||||
_pendingYubiKeyAction.postValue(null)
|
_pendingYubiKeyAction.postValue(null)
|
||||||
if (!isUsbKeyConnected) {
|
if (!_isUsbKey) {
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
requestHideDialog()
|
requestHideDialog()
|
||||||
}
|
}
|
||||||
@ -147,7 +162,7 @@ class MainViewModel : ViewModel() {
|
|||||||
|
|
||||||
suspend fun yubikeyAttached(device: YubiKeyDevice) {
|
suspend fun yubikeyAttached(device: YubiKeyDevice) {
|
||||||
|
|
||||||
isUsbKeyConnected = device is UsbYubiKeyDevice
|
_isUsbKey = device is UsbYubiKeyDevice
|
||||||
|
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
if (pendingYubiKeyAction.value != null) {
|
if (pendingYubiKeyAction.value != null) {
|
||||||
@ -176,13 +191,11 @@ class MainViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun yubikeyDetached() {
|
fun yubikeyDetached() {
|
||||||
|
if (_isUsbKey) {
|
||||||
if (isUsbKeyConnected) {
|
// clear keys from memory
|
||||||
// forget the current password only for usb keys
|
_memoryKeyProvider.clearAll()
|
||||||
_oathSessionPassword = null
|
|
||||||
_fManagementApi.updateDeviceInfo("") {}
|
_fManagementApi.updateDeviceInfo("") {}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onDialogClosed(result: Pigeon.Result<Void>) {
|
fun onDialogClosed(result: Pigeon.Result<Void>) {
|
||||||
@ -206,8 +219,7 @@ class MainViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun <T> withUnlockedSession(session: OathSession, block: (OathSession) -> T): T {
|
private fun <T> withUnlockedSession(session: OathSession, block: (OathSession) -> T): T {
|
||||||
val isLocked = isOathSessionLocked(session)
|
if (!tryToUnlockOathSession(session)) {
|
||||||
if (isLocked) {
|
|
||||||
throw Exception("Session is locked")
|
throw Exception("Session is locked")
|
||||||
}
|
}
|
||||||
return block(session)
|
return block(session)
|
||||||
@ -320,9 +332,9 @@ class MainViewModel : ViewModel() {
|
|||||||
throw Exception("Provided current password is invalid")
|
throw Exception("Provided current password is invalid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val newPass = password.toCharArray()
|
val accessKey = session.deriveAccessKey(password.toCharArray())
|
||||||
session.setPassword(newPass)
|
session.setAccessKey(accessKey)
|
||||||
_oathSessionPassword = newPass
|
_keyManager.addKey(session.deviceId, accessKey, false)
|
||||||
Logger.d("Successfully set password")
|
Logger.d("Successfully set password")
|
||||||
}
|
}
|
||||||
} catch (cause: Throwable) {
|
} catch (cause: Throwable) {
|
||||||
@ -341,7 +353,7 @@ class MainViewModel : ViewModel() {
|
|||||||
// test current password sent by the user
|
// test current password sent by the user
|
||||||
if (session.unlock(currentPassword.toCharArray())) {
|
if (session.unlock(currentPassword.toCharArray())) {
|
||||||
session.deleteAccessKey()
|
session.deleteAccessKey()
|
||||||
_oathSessionPassword = null
|
_keyManager.removeKey(session.deviceId)
|
||||||
Logger.d("Successfully unset password")
|
Logger.d("Successfully unset password")
|
||||||
result.success(null)
|
result.success(null)
|
||||||
return@useOathSession
|
return@useOathSession
|
||||||
@ -369,7 +381,7 @@ class MainViewModel : ViewModel() {
|
|||||||
fun refreshOathCodes(result: Pigeon.Result<String>) {
|
fun refreshOathCodes(result: Pigeon.Result<String>) {
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
if (!isUsbKeyConnected) {
|
if (!_isUsbKey) {
|
||||||
throw Exception("Cannot refresh for nfc key")
|
throw Exception("Cannot refresh for nfc key")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -411,28 +423,33 @@ class MainViewModel : ViewModel() {
|
|||||||
fun unlockOathSession(
|
fun unlockOathSession(
|
||||||
password: String,
|
password: String,
|
||||||
remember: Boolean,
|
remember: Boolean,
|
||||||
result: Pigeon.Result<Boolean>
|
result: Pigeon.Result<Pigeon.UnlockResponse>
|
||||||
) {
|
) {
|
||||||
|
|
||||||
viewModelScope.launch(Dispatchers.IO) {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
var codes: String? = null
|
var codes: String? = null
|
||||||
useOathSession("Unlocking", true) {
|
useOathSession("Unlocking", true) {
|
||||||
_oathSessionPassword = password.toCharArray()
|
val accessKey = it.deriveAccessKey(password.toCharArray())
|
||||||
val isLocked = isOathSessionLocked(it)
|
_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)
|
codes = calculateOathCodes(it)
|
||||||
.toJson(it.deviceId)
|
.toJson(it.deviceId)
|
||||||
.toString()
|
.toString()
|
||||||
}
|
}
|
||||||
|
result.success(response)
|
||||||
result.success(!isLocked)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
codes?.let {
|
codes?.let {
|
||||||
|
viewModelScope.launch(Dispatchers.Main) {
|
||||||
_fOathApi.updateOathCredentials(it) {}
|
_fOathApi.updateOathCredentials(it) {}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} catch (cause: Throwable) {
|
} catch (cause: Throwable) {
|
||||||
result.error(cause)
|
result.error(cause)
|
||||||
@ -459,7 +476,7 @@ class MainViewModel : ViewModel() {
|
|||||||
queryUserToTap: Boolean,
|
queryUserToTap: Boolean,
|
||||||
action: (OathSession) -> T
|
action: (OathSession) -> T
|
||||||
) = suspendCoroutine<T> { outer ->
|
) = suspendCoroutine<T> { outer ->
|
||||||
if (queryUserToTap && !isUsbKeyConnected) {
|
if (queryUserToTap && !_isUsbKey) {
|
||||||
viewModelScope.launch(Dispatchers.Main) {
|
viewModelScope.launch(Dispatchers.Main) {
|
||||||
requestShowDialog(title)
|
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) {
|
if (!session.isLocked) {
|
||||||
return false
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_oathSessionPassword == null) {
|
val deviceId = session.deviceId
|
||||||
return true // we have no password to unlock
|
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) {
|
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
|
_keyManager.removeKey(deviceId) // remove invalid access keys from [KeyManager]
|
||||||
return true // the unlock did not work, session is locked
|
return false // the unlock did not work, session is locked
|
||||||
}
|
}
|
||||||
|
|
||||||
fun forgetPassword(result: Pigeon.Result<Void>) {
|
fun forgetPassword(result: Pigeon.Result<Void>) {
|
||||||
|
_keyManager.clearAll()
|
||||||
|
Logger.d("Cleared all keys.")
|
||||||
result.success(null)
|
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/foundation.dart' show WriteBuffer, ReadBuffer;
|
||||||
import 'package:flutter/services.dart';
|
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 {
|
class _OathApiCodec extends StandardMessageCodec {
|
||||||
const _OathApiCodec();
|
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 {
|
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?>(
|
final BasicMessageChannel<Object?> channel = BasicMessageChannel<Object?>(
|
||||||
'dev.flutter.pigeon.OathApi.unlock', codec, binaryMessenger: _binaryMessenger);
|
'dev.flutter.pigeon.OathApi.unlock', codec, binaryMessenger: _binaryMessenger);
|
||||||
final Map<Object?, Object?>? replyMap =
|
final Map<Object?, Object?>? replyMap =
|
||||||
@ -67,7 +113,7 @@ class OathApi {
|
|||||||
message: 'Host platform returned null value for non-null return value.',
|
message: 'Host platform returned null value for non-null return value.',
|
||||||
);
|
);
|
||||||
} else {
|
} 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,
|
Future<Pair<bool, bool>> unlock(String password,
|
||||||
{bool remember = false}) async {
|
{bool remember = false}) async {
|
||||||
try {
|
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');
|
_log.config('applet unlocked');
|
||||||
setData(state.value!.copyWith(locked: false));
|
setData(state.value!.copyWith(locked: false));
|
||||||
}
|
}
|
||||||
return Pair(unlockSuccess, false); // TODO: provide correct second param
|
return Pair(unlocked, remembered);
|
||||||
} on PlatformException catch (e) {
|
} on PlatformException catch (e) {
|
||||||
_log.config('Calling unlock failed with exception: $e');
|
_log.config('Calling unlock failed with exception: $e');
|
||||||
return Pair(false, false);
|
return Pair(false, false);
|
||||||
|
@ -1,12 +1,17 @@
|
|||||||
import 'package:pigeon/pigeon.dart';
|
import 'package:pigeon/pigeon.dart';
|
||||||
|
|
||||||
|
class UnlockResponse {
|
||||||
|
bool? isUnlocked;
|
||||||
|
bool? isRemembered;
|
||||||
|
}
|
||||||
|
|
||||||
@HostApi()
|
@HostApi()
|
||||||
abstract class OathApi {
|
abstract class OathApi {
|
||||||
@async
|
@async
|
||||||
void reset();
|
void reset();
|
||||||
|
|
||||||
@async
|
@async
|
||||||
bool unlock(String password, bool remember);
|
UnlockResponse unlock(String password, bool remember);
|
||||||
|
|
||||||
@async
|
@async
|
||||||
void setPassword(String? currentPassword, String newPassword);
|
void setPassword(String? currentPassword, String newPassword);
|
||||||
|
Loading…
Reference in New Issue
Block a user