feat(@desktop/onboarding): support (optionally) OS keychain to login password

This feature works for MacOs only, for now.

On login, whether new or already created user may select between options:
"Store" - store password to the Keychain
"Not now" - don't store it now, but ask next time again
"Never" - don't store them ever and don't ask again

Selected preference may be changed later in:
`ProfileSettings > Privacy and security > Store pass to Keychain`

On the next app run, if `Store` was selected, a user will be asked to confirm
his identity using Touch Id in order to log in the app. If any error happens
he will be able to login using password.

Fixes: #2675
This commit is contained in:
Sale Djenic 2021-09-13 13:51:47 +02:00 committed by Iuri Matias
parent e0c53b7012
commit 8af104a16e
14 changed files with 356 additions and 49 deletions

View File

@ -129,7 +129,7 @@ ifneq ($(detected_OS),Windows)
QT5_LIBDIR := $(QTDIR)/lib QT5_LIBDIR := $(QTDIR)/lib
# some manually installed Qt5 instances have wrong paths in their *.pc files, so we pass the right one to the linker here # some manually installed Qt5 instances have wrong paths in their *.pc files, so we pass the right one to the linker here
ifeq ($(detected_OS),Darwin) ifeq ($(detected_OS),Darwin)
NIM_PARAMS += -L:"-framework Foundation -framework Security -framework IOKit -framework CoreServices" NIM_PARAMS += -L:"-framework Foundation -framework Security -framework IOKit -framework CoreServices -framework LocalAuthentication"
# Fix for failures due to 'can't allocate code signature data for' # Fix for failures due to 'can't allocate code signature data for'
NIM_PARAMS += --passL:"-headerpad_max_install_names" NIM_PARAMS += --passL:"-headerpad_max_install_names"
NIM_PARAMS += --passL:"-F$(QT5_LIBDIR)" NIM_PARAMS += --passL:"-F$(QT5_LIBDIR)"

View File

@ -1,19 +1,22 @@
import NimQml, chronicles, options, std/wrapnils import NimQml, chronicles, options, std/wrapnils
import status/[signals, status] import status/[signals, status]
import status/types/[account, rpc_response] import status/types/[account, rpc_response]
import ../../app_service/[main]
import view import view
import eventemitter import eventemitter
import ../../constants import ../../constants
type LoginController* = ref object type LoginController* = ref object
status*: Status status*: Status
appService: AppService
view*: LoginView view*: LoginView
variant*: QVariant variant*: QVariant
proc newController*(status: Status): LoginController = proc newController*(status: Status, appService: AppService): LoginController =
result = LoginController() result = LoginController()
result.status = status result.status = status
result.view = newLoginView(status) result.appService = appService
result.view = newLoginView(status, appService)
result.variant = newQVariant(result.view) result.variant = newQVariant(result.view)
proc delete*(self: LoginController) = proc delete*(self: LoginController) =

View File

@ -2,8 +2,15 @@ import NimQml, Tables, json, nimcrypto, strformat, json_serialization, chronicle
import status/accounts as AccountModel import status/accounts as AccountModel
import status/types/[account, rpc_response] import status/types/[account, rpc_response]
import status/[status] import status/[status]
import ../../app_service/[main]
import ../onboarding/views/account_info import ../onboarding/views/account_info
const ERROR_TYPE_AUTHENTICATION = "authentication"
const ERROR_TYPE_KEYCHAIN = "keychain"
logScope:
topics = "login-model"
type type
AccountRoles {.pure.} = enum AccountRoles {.pure.} = enum
Username = UserRole + 1 Username = UserRole + 1
@ -15,23 +22,32 @@ type
QtObject: QtObject:
type LoginView* = ref object of QAbstractListModel type LoginView* = ref object of QAbstractListModel
status: Status status: Status
appService: AppService
accounts: seq[NodeAccount] accounts: seq[NodeAccount]
currentAccount*: AccountInfoView currentAccount*: AccountInfoView
isCurrentFlow*: bool isCurrentFlow*: bool
keychainManager*: StatusKeychainManager
proc setup(self: LoginView) = proc setup(self: LoginView) =
self.QAbstractListModel.setup self.QAbstractListModel.setup
self.keychainManager = newStatusKeychainManager("StatusDesktop", "authenticate you")
signalConnect(self.keychainManager, "success(QString)", self,
"onKeychainManagerSuccess(QString)", 2)
signalConnect(self.keychainManager, "error(QString, int, QString)", self,
"onKeychainManagerError(QString, int, QString)", 2)
proc delete*(self: LoginView) = proc delete*(self: LoginView) =
self.currentAccount.delete self.currentAccount.delete
self.accounts = @[] self.accounts = @[]
self.keychainManager.delete
self.QAbstractListModel.delete self.QAbstractListModel.delete
proc newLoginView*(status: Status): LoginView = proc newLoginView*(status: Status, appService: AppService): LoginView =
new(result, delete) new(result, delete)
result.accounts = @[] result.accounts = @[]
result.currentAccount = newAccountInfoView() result.currentAccount = newAccountInfoView()
result.status = status result.status = status
result.appService = appService
result.isCurrentFlow = false result.isCurrentFlow = false
result.setup result.setup
@ -139,3 +155,46 @@ QtObject:
read = isCurrentFlow read = isCurrentFlow
write = setCurrentFlow write = setCurrentFlow
notify = currentFlowChanged notify = currentFlowChanged
proc storePassword*(self: LoginView, username: string, password: string) {.slot.} =
# The following check is commented out only because we maintaing a single file
# using two QSettings instances, one created in qml and one here in nim part.
# Once we move to maintain settings file only via nim part this condition need
# to be applied. The reason why it's commented out is, if you change something
# from the qml part and try in a next step to read that property from the nim
# part, that property won't be read correctly cause even 'sync' method is called
# we need to wait untill the event loop ends, cause data are flushed at regular
# intervals to the file.
# let value = self.appService.localSettingsService.getValue(
# LS_KEY_STORE_TO_KEYCHAIN).stringVal
# if (value == LS_VALUE_STORE):
if (username.len > 0):
self.keychainManager.storeDataAsync(username, password)
proc tryToObtainPassword*(self: LoginView) {.slot.} =
let value = self.appService.localSettingsService.getValue(
LS_KEY_STORE_TO_KEYCHAIN).stringVal
if (value == LS_VALUE_STORE):
self.keychainManager.readDataAsync(self.currentAccount.username)
proc obtainingPasswordError*(self:LoginView, errorDescription: string) {.signal.}
proc obtainingPasswordSuccess*(self:LoginView, password: string) {.signal.}
proc onKeychainManagerError*(self: LoginView, errType: string, code: int,
errorDescription: string) {.slot.} =
## This slot is called in case an error occured while we're dealing with
## KeychainManager. So far we're just logging the error.
info "KeychainManager stopped: ", msg = code, errorDescription
if (errType == ERROR_TYPE_AUTHENTICATION):
return
# We are notifying user only about keychain errors.
self.appService.localSettingsService.removeValue(LS_KEY_STORE_TO_KEYCHAIN)
self.obtainingPasswordError(errorDescription)
proc onKeychainManagerSuccess*(self: LoginView, data: string) {.slot.} =
## This slot is called in case a password is successfully retrieved from the
## Keychain. In this case @data contains required password.
self.obtainingPasswordSuccess(data)

View File

@ -1,7 +1,8 @@
import NimQml, Tables, json, nimcrypto, strformat, json_serialization, strutils import NimQml, Tables, json, nimcrypto, strformat, json_serialization, strutils
import status/accounts as AccountModel import status/accounts as AccountModel
import status/[status, wallet] import status/[status, wallet]
import status/types/[account, rpc_response] import status/types/[rpc_response]
import status/types/account as status_account_type
import views/account_info import views/account_info
type type
@ -99,8 +100,13 @@ QtObject:
result = self.status.wallet.validateMnemonic(mnemonic.strip()) result = self.status.wallet.validateMnemonic(mnemonic.strip())
proc storeDerivedAndLogin(self: OnboardingView, password: string): string {.slot.} = proc storeDerivedAndLogin(self: OnboardingView, password: string): string {.slot.} =
# In this moment we're sure that new account will be logged in, and emit signal.
let genAcc = self.currentAccount.account
let acc = Account(name: genAcc.name, keyUid: genAcc.keyUid, identicon: genAcc.identicon, identityImage: genAcc.identityImage)
self.status.events.emit("currentAccountUpdated", status_account_type.AccountArgs(account: acc))
try: try:
result = self.status.accounts.storeDerivedAndLogin(self.status.fleet.config, self.currentAccount.account, password).toJson result = self.status.accounts.storeDerivedAndLogin(self.status.fleet.config, genAcc, password).toJson
except StatusGoException as e: except StatusGoException as e:
var msg = e.msg var msg = e.msg
if e.msg.contains("account already exists"): if e.msg.contains("account already exists"):

View File

@ -5,6 +5,7 @@ import QtGraphicalEffects 1.13
import "../../../../imports" import "../../../../imports"
import "../../../../shared" import "../../../../shared"
import "../../../../shared/status" import "../../../../shared/status"
import "../../../../onboarding/" as OnboardingComponents
Item { Item {
id: privacyContainer id: privacyContainer
@ -45,6 +46,35 @@ Item {
} }
} }
StatusSettingsLineButton {
text: qsTr("Store pass to Keychain")
visible: Qt.platform.os == "osx" // For now, this is available only on MacOS
currentValue: {
let value = appSettings.storeToKeychain
if(value == Constants.storeToKeychainValueStore)
return qsTr("Store")
if(value == Constants.storeToKeychainValueNever)
return qsTr("Never")
return qsTr("Not now")
}
onClicked: openPopup(storeToKeychainSelectionModal)
Component {
id: storePasswordModal
OnboardingComponents.CreatePasswordModal {
storingPasswordModal: true
height: 350
}
}
Component {
id: storeToKeychainSelectionModal
StoreToKeychainSelectionModal {}
}
}
BackupSeedModal { BackupSeedModal {
id: backupSeedModal id: backupSeedModal
} }

View File

@ -0,0 +1,77 @@
import QtQuick 2.13
import QtQuick.Controls 2.13
import "../../../../imports"
import "../../../../shared"
import "../../../../shared/status"
ModalPopup {
id: popup
title: qsTr("Store pass to Keychain")
onClosed: {
destroy()
}
Column {
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.rightMargin: Style.current.padding
anchors.leftMargin: Style.current.padding
spacing: 0
ButtonGroup {
id: openLinksWithGroup
}
StatusRadioButtonRow {
text: qsTr("Store")
buttonGroup: openLinksWithGroup
checked: appSettings.storeToKeychain === Constants.storeToKeychainValueStore
onRadioCheckedChanged: {
if (checked && appSettings.storeToKeychain !== Constants.storeToKeychainValueStore) {
var storePassPopup = openPopup(storePasswordModal)
if(storePassPopup)
{
storePassPopup.closed.connect(function(){
if (appSettings.storeToKeychain === Constants.storeToKeychainValueStore)
popup.close()
else if (appSettings.storeToKeychain === Constants.storeToKeychainValueNotNow)
notNowBtn.checked = true
else if (appSettings.storeToKeychain === Constants.storeToKeychainValueNever)
neverBtn.checked = true
})
}
}
}
}
StatusRadioButtonRow {
id: notNowBtn
text: qsTr("Not now")
buttonGroup: openLinksWithGroup
checked: appSettings.storeToKeychain === Constants.storeToKeychainValueNotNow ||
appSettings.storeToKeychain === ""
onRadioCheckedChanged: {
if (checked) {
appSettings.storeToKeychain = Constants.storeToKeychainValueNotNow
}
}
}
StatusRadioButtonRow {
id: neverBtn
text: qsTr("Never")
buttonGroup: openLinksWithGroup
checked: appSettings.storeToKeychain === Constants.storeToKeychainValueNever
onRadioCheckedChanged: {
if (checked) {
appSettings.storeToKeychain = Constants.storeToKeychainValueNever
}
}
}
}
}

View File

@ -163,6 +163,10 @@ QtObject {
readonly property string ens_connected: "connected" readonly property string ens_connected: "connected"
readonly property string ens_connected_dkey: "connected-different-key" readonly property string ens_connected_dkey: "connected-different-key"
readonly property string storeToKeychainValueStore: "store"
readonly property string storeToKeychainValueNotNow: "notNow"
readonly property string storeToKeychainValueNever: "never"
//% "(edited)" //% "(edited)"
readonly property string editLabel: ` <span class="isEdited">` + qsTrId("-edited-") + `</span>` readonly property string editLabel: ` <span class="isEdited">` + qsTrId("-edited-") + `</span>`

View File

@ -41,6 +41,7 @@ StatusWindow {
Settings { Settings {
id: appSettings id: appSettings
fileName: profileModel.settingsFile fileName: profileModel.settingsFile
property string storeToKeychain: ""
property var chatSplitView property var chatSplitView
property var walletSplitView property var walletSplitView
@ -277,6 +278,60 @@ StatusWindow {
} }
} }
function checkForStoringPassToKeychain(username, password, clearStoredValue) {
if(Qt.platform.os == "osx")
{
if(clearStoredValue)
{
appSettings.storeToKeychain = ""
}
if(appSettings.storeToKeychain === "" ||
appSettings.storeToKeychain === Constants.storeToKeychainValueNotNow)
{
storeToKeychainConfirmationPopup.password = password
storeToKeychainConfirmationPopup.username = username
storeToKeychainConfirmationPopup.open()
}
}
}
ConfirmationDialog {
id: storeToKeychainConfirmationPopup
property string password: ""
property string username: ""
height: 200
confirmationText: qsTr("Would you like to store password to the Keychain?")
showRejectButton: true
showCancelButton: true
confirmButtonLabel: qsTr("Store")
rejectButtonLabel: qsTr("Not now")
cancelButtonLabel: qsTr("Never")
function finish()
{
password = ""
username = ""
storeToKeychainConfirmationPopup.close()
}
onConfirmButtonClicked: {
appSettings.storeToKeychain = Constants.storeToKeychainValueStore
loginModel.storePassword(username, password)
finish()
}
onRejectButtonClicked: {
appSettings.storeToKeychain = Constants.storeToKeychainValueNotNow
finish()
}
onCancelButtonClicked: {
appSettings.storeToKeychain = Constants.storeToKeychainValueNever
finish()
}
}
DSM.StateMachine { DSM.StateMachine {
id: stateMachine id: stateMachine
initialState: onboardingState initialState: onboardingState

View File

@ -11,10 +11,13 @@ ModalPopup {
property bool repeatPasswordFieldValid: false property bool repeatPasswordFieldValid: false
property string passwordValidationError: "" property string passwordValidationError: ""
property string repeatPasswordValidationError: "" property string repeatPasswordValidationError: ""
property bool storingPasswordModal: false
id: popup id: popup
//% "Create a password" title: storingPasswordModal?
title: qsTrId("intro-wizard-title-alt4") qsTr("Store password") :
//% "Create a password"
qsTrId("intro-wizard-title-alt4")
height: 500 height: 500
onOpened: { onOpened: {
@ -27,9 +30,11 @@ ModalPopup {
anchors.rightMargin: 56 anchors.rightMargin: 56
anchors.leftMargin: 56 anchors.leftMargin: 56
anchors.top: parent.top anchors.top: parent.top
anchors.topMargin: 88 anchors.topMargin: storingPasswordModal? Style.current.xlPadding : 88
//% "New password..." placeholderText: storingPasswordModal?
placeholderText: qsTrId("new-password...") qsTr("Current password...") :
//% "New password..."
qsTrId("new-password...")
textField.echoMode: TextInput.Password textField.echoMode: TextInput.Password
onTextChanged: { onTextChanged: {
[firstPasswordFieldValid, passwordValidationError] = [firstPasswordFieldValid, passwordValidationError] =
@ -79,6 +84,7 @@ ModalPopup {
} }
StyledText { StyledText {
visible: !storingPasswordModal
//% "At least 6 characters. You will use this password to unlock status on this device & sign transactions." //% "At least 6 characters. You will use this password to unlock status on this device & sign transactions."
text: qsTrId("at-least-6-characters-you-will-use-this-password-to-unlock-status-on-this-device-sign-transactions.") text: qsTrId("at-least-6-characters-you-will-use-this-password-to-unlock-status-on-this-device-sign-transactions.")
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
@ -103,8 +109,11 @@ ModalPopup {
anchors.topMargin: Style.current.padding anchors.topMargin: Style.current.padding
anchors.right: parent.right anchors.right: parent.right
state: loading ? "pending" : "default" state: loading ? "pending" : "default"
//% "Create password"
text: qsTrId("create-password") text: storingPasswordModal?
qsTr("Store password") :
//% "Create password"
qsTrId("create-password")
enabled: firstPasswordFieldValid && repeatPasswordFieldValid && !loading enabled: firstPasswordFieldValid && repeatPasswordFieldValid && !loading
@ -146,23 +155,29 @@ ModalPopup {
} }
onClicked: { onClicked: {
loading = true if (storingPasswordModal)
loginModel.isCurrentFlow = false; {
onboardingModel.isCurrentFlow = true; appSettings.storeToKeychain = Constants.storeToKeychainValueStore
const result = onboardingModel.storeDerivedAndLogin(repeatPasswordField.text); loginModel.storePassword(profileModel.profile.username, repeatPasswordField.text)
const error = JSON.parse(result).error popup.close()
if (error) { }
importError.text += error else
return importError.open() {
loading = true
loginModel.isCurrentFlow = false;
onboardingModel.isCurrentFlow = true;
const result = onboardingModel.storeDerivedAndLogin(repeatPasswordField.text);
const error = JSON.parse(result).error
if (error) {
importError.text += error
return importError.open()
}
onboardingModel.firstTimeLogin = true
applicationWindow.checkForStoringPassToKeychain(onboardingModel.currentAccount.username,
repeatPasswordField.text, true)
} }
onboardingModel.firstTimeLogin = true
} }
} }
} }
} }
/*##^##
Designer {
D{i:0;formeditorColor:"#ffffff";height:500;width:400}
}
##^##*/

View File

@ -21,8 +21,63 @@ Item {
onboardingModel.isCurrentFlow = !isLogin; onboardingModel.isCurrentFlow = !isLogin;
} }
function doLogin(password) {
if (loading || password.length === 0)
return
setCurrentFlow(true);
loading = true
loginModel.login(password)
applicationWindow.checkForStoringPassToKeychain(loginModel.currentAccount.username, password, false)
txtPassword.textField.clear()
}
function resetLogin() {
if(appSettings.storeToKeychain === Constants.storeToKeychainValueStore)
{
connection.enabled = true
txtPassword.visible = false
loginModel.tryToObtainPassword()
}
else
{
txtPassword.visible = true
txtPassword.forceActiveFocus(Qt.MouseFocusReason)
}
}
Component.onCompleted: { Component.onCompleted: {
txtPassword.forceActiveFocus(Qt.MouseFocusReason) resetLogin()
}
Connections{
id: connection
target: loginModel
onObtainingPasswordError: {
enabled = false
obtainingPasswordErrorNotification.confirmationText = errorDescription
obtainingPasswordErrorNotification.open()
}
onObtainingPasswordSuccess: {
enabled = false
doLogin(password)
}
}
ConfirmationDialog {
id: obtainingPasswordErrorNotification
height: 270
confirmButtonLabel: qsTr("Ok")
onConfirmButtonClicked: {
close()
}
onClosed: {
txtPassword.visible = true
}
} }
Item { Item {
@ -60,6 +115,7 @@ Item {
id: selectAnotherAccountModal id: selectAnotherAccountModal
onAccountSelect: function (index) { onAccountSelect: function (index) {
loginModel.setCurrentAccount(index) loginModel.setCurrentAccount(index)
resetLogin()
} }
onOpenModalClick: function () { onOpenModalClick: function () {
setCurrentFlow(true); setCurrentFlow(true);
@ -127,7 +183,7 @@ Item {
textField.echoMode: TextInput.Password textField.echoMode: TextInput.Password
textField.focus: true textField.focus: true
Keys.onReturnPressed: { Keys.onReturnPressed: {
submitBtn.clicked() doLogin(textField.text)
} }
onTextEdited: { onTextEdited: {
errMsg.visible = false errMsg.visible = false
@ -143,18 +199,12 @@ Item {
icon.width: 18 icon.width: 18
icon.height: 14 icon.height: 14
opacity: (loading || txtPassword.text.length > 0) ? 1 : 0 opacity: (loading || txtPassword.text.length > 0) ? 1 : 0
anchors.left: txtPassword.right anchors.left: txtPassword.visible? txtPassword.right : changeAccountBtn.right
anchors.leftMargin: (loading || txtPassword.text.length > 0) ? Style.current.padding : Style.current.smallPadding anchors.leftMargin: (loading || txtPassword.text.length > 0) ? Style.current.padding : Style.current.smallPadding
anchors.verticalCenter: txtPassword.verticalCenter anchors.verticalCenter: txtPassword.visible? txtPassword.verticalCenter : changeAccountBtn.verticalCenter
state: loading ? "pending" : "default" state: loading ? "pending" : "default"
onClicked: { onClicked: {
if (loading) { doLogin(txtPassword.textField.text)
return;
}
setCurrentFlow(true);
loading = true
loginModel.login(txtPassword.textField.text)
txtPassword.textField.clear()
} }
// https://www.figma.com/file/BTS422M9AkvWjfRrXED3WC/%F0%9F%91%8B-Onboarding%E2%8E%9CDesktop?node-id=6%3A0 // https://www.figma.com/file/BTS422M9AkvWjfRrXED3WC/%F0%9F%91%8B-Onboarding%E2%8E%9CDesktop?node-id=6%3A0
@ -194,7 +244,7 @@ Item {
id: generateKeysLinkText id: generateKeysLinkText
//% "Generate new keys" //% "Generate new keys"
text: qsTrId("generate-new-keys") text: qsTrId("generate-new-keys")
anchors.top: txtPassword.bottom anchors.top: txtPassword.visible? txtPassword.bottom : changeAccountBtn.bottom
anchors.topMargin: 16 anchors.topMargin: 16
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
font.pixelSize: 13 font.pixelSize: 13
@ -219,9 +269,3 @@ Item {
} }
} }
} }
/*##^##
Designer {
D{i:0;autoSize:true;formeditorColor:"#ffffff";formeditorZoom:0.75;height:480;width:640}
}
##^##*/

View File

@ -14,11 +14,14 @@ StatusModal {
property Popup parentPopup property Popup parentPopup
property var value property var value
property var executeConfirm property var executeConfirm
property var executeReject
property var executeCancel property var executeCancel
property string btnType: "warn" property string btnType: "warn"
property string confirmButtonLabel: qsTr("Confirm") property string confirmButtonLabel: qsTr("Confirm")
property string rejectButtonLabel: qsTr("Reject")
property string cancelButtonLabel: qsTr("Cancel") property string cancelButtonLabel: qsTr("Cancel")
property string confirmationText: qsTr("Are you sure you want to do this?") property string confirmationText: qsTr("Are you sure you want to do this?")
property bool showRejectButton: false
property bool showCancelButton: false property bool showCancelButton: false
property alias checkbox: checkbox property alias checkbox: checkbox
@ -27,6 +30,7 @@ StatusModal {
focus: visible focus: visible
signal confirmButtonClicked() signal confirmButtonClicked()
signal rejectButtonClicked()
signal cancelButtonClicked() signal cancelButtonClicked()
@ -83,6 +87,16 @@ StatusModal {
confirmationDialog.cancelButtonClicked() confirmationDialog.cancelButtonClicked()
} }
}, },
StatusFlatButton {
visible: showRejectButton
text: confirmationDialog.rejectButtonLabel
onClicked: {
if (executeReject && typeof executeReject === "function") {
executeReject()
}
confirmationDialog.rejectButtonClicked()
}
},
StatusButton { StatusButton {
id: confirmButton id: confirmButton
type: { type: {

View File

@ -7,7 +7,7 @@ import "."
Rectangle { Rectangle {
property alias text: textElement.text property alias text: textElement.text
property var buttonGroup property var buttonGroup
property bool checked: false property alias checked: radioButton.checked
property bool isHovered: false property bool isHovered: false
signal radioCheckedChanged(checked: bool) signal radioCheckedChanged(checked: bool)

2
vendor/DOtherSide vendored

@ -1 +1 @@
Subproject commit 0f8ed95fc7a47e4d3efb218ef961d36e60610cb3 Subproject commit b1e4d3b68629a101e21cfdfd448ef6f54364f235

2
vendor/nimqml vendored

@ -1 +1 @@
Subproject commit 00ee27ca52bcf5216c0de0e19e594ddfc1790452 Subproject commit 4351b9a61f7ff2b6798cadd4151b34b3c0670a56