package net.sergeych.superlogin.client import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.serialization.Serializable import net.sergeych.boss_serialization.BossDecoder import net.sergeych.boss_serialization_mp.BossEncoder import net.sergeych.mp_logger.* import net.sergeych.mp_tools.globalLaunch import net.sergeych.parsec3.* import net.sergeych.superlogin.* import net.sergeych.superlogin.server.SuperloginRestoreAccessPayload import net.sergeych.unikrypto.* import kotlin.reflect.KType import kotlin.reflect.typeOf /** * Application might want to serialize and store updated instances of it in some * permanent storage to restore login data also in offline more, and re-login * automatically where possible withot asking the password again. * * Superlogin client _does not store the data itself_ but only notifies client software * that it is changed and ought to be updated. */ @Serializable data class SuperloginData( val loginName: String, val loginToken: ByteArray? = null, val data: T? = null, ) @Serializable class ClientState(val slData: SuperloginData, val dataKey: SymmetricKey) { val loginName by slData::loginName val loginToken by slData::loginToken val data by slData::data constructor(loginName: String,loginToken: ByteArray?,data: T?, dataKey: SymmetricKey) : this(SuperloginData(loginName, loginToken, data), dataKey) } class SuperloginClient( private val transport: Parsec3Transport, savedData: ClientState? = null, private val dataType: KType, override val exceptionsRegistry: ExceptionsRegistry = ExceptionsRegistry(), ) : Parsec3Transport, Loggable by LogTag("SLCLI") { private val _state = MutableStateFlow( if (savedData == null) LoginState.LoggedOut else LoginState.LoggedIn(savedData) ) val state: StateFlow = _state private val _cflow = MutableStateFlow(false) override val connectedFlow: StateFlow = _cflow private var clientState: ClientState? = savedData set(value) { if (field != value) { field = value if (value == null) { // do actual disconnect work _cflow.value = false _state.value = LoginState.LoggedOut } else { val v = _state.value if (v !is LoginState.LoggedIn<*> || v.loginData != value) { _state.value = LoginState.LoggedIn(value) } } } } @Suppress("UNCHECKED_CAST") val applicationData: D? get() = (state.value as? LoginState.LoggedIn)?.loginData?.data private var adapterReady = MutableStateFlow(false) /** * The flow that tracks readiness state of the connetion adapter. In other works, * when its value is false, [adapter] deferred is not completed and [call] method * will wait until it is ready. * * The reason for it is as follows: when connetion drops, * superlogin client awaits its automatic restore (by parsec3) and then tries to re-login. * Until this login restore will finish either successful or not, calling parsec3 commands * may produce unpredictable results, so it is automatically postponed until login state * is restored. This is completely transparent to the caller, and this state flow allows * client to be notified on actual connection state. */ val connectionReady = adapterReady.asStateFlow() override suspend fun adapter(): Adapter { adapterReady.waitFor(true) return transport.adapter() } /** * Call client API commands with it (uses [adapter] under the hood) */ suspend fun call(ca: CommandDescriptor, args: A): R = adapter().invokeCommand(ca, args) /** * Call client API commands with it (uses [adapter] under the hood) */ suspend fun call(ca: CommandDescriptor): R = adapter().invokeCommand(ca) private suspend fun invoke(ca: CommandDescriptor, args: A): R = transport.adapter().invokeCommand(ca, args) private suspend fun invoke(ca: CommandDescriptor): R = transport.adapter().invokeCommand(ca) private var jobs = listOf() private val serverApi = SuperloginServerApi() private suspend fun tryRestoreLogin() { clientState?.let { clientState -> clientState.loginToken?.let { token -> debug { "trying to restore login with a token" } while (true) { try { val ar = transport.adapter().invokeCommand(serverApi.slLoginByToken, token) this.clientState = if (ar is AuthenticationResult.Success) { val data: D? = ar.applicationData?.let { BossDecoder.decodeFrom(dataType, it) } debug { "login restored by the token: ${ar.loginName}" } ClientState(ar.loginName, ar.loginToken, data, clientState.dataKey) } else { debug { "failed to restore login by the token: $ar" } null } break } catch (t: Throwable) { exception { "failed to restore login by token, will retry" to t } delay(1500) } } } } ?: warning { "tryRestoreLogin is ignored as slData is now null" } adapterReady.value = true } init { transport.registerExceptinos(SuperloginExceptionsRegistry) jobs += globalLaunch { transport.connectedFlow.collect { on -> if (on) tryRestoreLogin() else { adapterReady.value = false _cflow.value = false } } } } override fun close() { transport.close() } /** * Force dropping and re-establish underlying parsec3 connection and restore * login state to the current. */ override fun reconnect() { adapterReady.value = false transport.reconnect() } private var registration: Registration? = null /** * Whether the client is supposed to be logged in. Note that it is also true when * there is no ready connection (means also offline), if there is information about staved * loged in state. It can change at aby time as server may drop login state too. Use * [state] flow to track the state changes and [adapterReady] flow to track connection state * that are in fact independent to some degree. */ val isLoggedIn get() = state.value.isLoggedIn /** * The data storage key, a random key created when registering. It is retrieved automatically * on login by password and on registration, _It does not restore when logged in by key * as it would require storing user's password which must not be done_. * Use [retrieveDataKey] to restore to with a password (when logged in) */ val dataKey: SymmetricKey? get() = clientState?.dataKey /** * Perform registration and login attempt and return the result. It automatically caches and reuses intermediate * long-calculated keys so it runs much fatser when called again with the same password. * * _Client should be in logged-out state!_ Use [logout] as needed * * If result is [Registration.Result.Success], user state is _logged in_. * * @throws IllegalStateException if logged in */ suspend fun register( loginName: String, password: String, data: D? = null, loginKeyStrength: Int = 2048, pbkdfRounds: Int = 15000, ): Registration.Result { mustBeLoggedOut() val rn = registration ?: Registration(transport.adapter(), loginKeyStrength, pbkdfRounds) .also { registration = it } return rn.registerWithData(loginName, password, extraData = BossEncoder.encode(dataType, data)) .also { rr -> if (rr is Registration.Result.Success) { clientState = ClientState(loginName, rr.loginToken, extractData(rr.encodedData), rr.dataKey) } } } private fun extractData(rr: ByteArray?): D? = rr?.let { BossDecoder.decodeFrom(dataType, it) } private fun mustBeLoggedOut() { if (isLoggedIn) throw IllegalStateException("please log out first") } private fun mustBeLoggedIn() { if (!isLoggedIn) throw IllegalStateException("please log in first") } suspend fun logout() { mustBeLoggedIn() invoke(serverApi.slLogout) clientState = null } /** * Try to log in by specified token, returned by [Registration.Result.Success.loginToken] or * [SuperloginData.loginToken] respectively. * * @return updated login data (and new token value) or null if token is not (or not anymore) * available for logging in. */ suspend fun loginByToken(token: ByteArray,newDataKey: SymmetricKey): ClientState? { mustBeLoggedOut() val r = invoke(serverApi.slLoginByToken, token) return when (r) { is AuthenticationResult.Success -> ClientState( r.loginName, r.loginToken, extractData(r.applicationData), newDataKey ).also { clientState = it } else -> null } } suspend fun loginByPassword(loginName: String, password: String): ClientState? { mustBeLoggedOut() // Request derivation params val params = invoke(serverApi.slRequestDerivationParams, loginName) val keys = DerivedKeys.derive(password, params) // Request login data by derived it return invoke( serverApi.slRequestACOByLoginName, RequestACOByLoginNameArgs(loginName, keys.loginId) ).let { loginRequest -> try { AccessControlObject.unpackWithKey( loginRequest.packedACO, keys.loginAccessKey )?.let { aco -> val result = invoke( serverApi.slLoginByKey, SignedRecord.pack( aco.payload.loginPrivateKey, LoginByPasswordPayload(loginName), loginRequest.nonce ) ) if (result is AuthenticationResult.Success) { ClientState(loginName, result.loginToken, extractData(result.applicationData), aco.payload.dataStorageKey) .also { clientState = it } } else null } } catch (t: Throwable) { debug { "error while signign in: $t" } null } } } // /** // * Try to retrieve dataKey (usually after login by token), it is impossible // * to do without a valid password key. Should be logged in. If [dataKey] is not null // * what means is already known, returns it immediatel, otherwise uses password // * to access ACO on the server and extract data key. // * @return data key or null if it is impossible to do (no connection or wrong password) // */ // suspend fun retrieveDataKey(password: String): SymmetricKey? { // mustBeLoggedIn() // dataKey?.let { return it } // // val loginName = slData?.loginName ?: throw SLInternalException("slData: empty login name") // try { // val params = invoke( // serverApi.slRequestDerivationParams, // loginName // ) // val keys = DerivedKeys.derive(password, params) // // Request login data by derived it // return invoke( // serverApi.slRequestACOByLoginName, // RequestACOByLoginNameArgs(loginName, keys.loginId) // ).let { loginRequest -> // AccessControlObject.unpackWithKey( // loginRequest.packedACO, // keys.loginAccessKey // )?.let { aco -> // aco.data.payload.dataStorageKey.also { dataKey = it } // } // } // } catch (t: Throwable) { // t.printStackTrace() // return null // } // } /** * Resets password and log in using a `secret` string (one that wwas reported on registration. __Never store * secrt string in your app__. Always ask user to enter it just before the operation and wipe it out * immediately after. It is a time-consuming procedure. Note that on success the client state changes * to [LoginState.LoggedIn]. * * @param secret the secret string as was reported when registering * @param newPassword new password (apply strength checks, it is not checked here) * @param loginKeyStrength desired login key strength (it will be generated there) * @param params password derivation params: it is possible to change its strength here * @return login data instance on success or null */ suspend fun resetPasswordAndSignIn( secret: String, newPassword: String, params: PasswordDerivationParams = PasswordDerivationParams(), loginKeyStrength: Int = 2048, ): ClientState? { mustBeLoggedOut() return try { val (id, key) = RestoreKey.parse(secret) val packedACO = invoke(serverApi.slRequestACOBySecretId, id) AccessControlObject.unpackWithKey(packedACO, key)?.let { changePasswordWithACO(it, newPassword, params, loginKeyStrength) clientState } } catch (x: RestoreKey.InvalidSecretException) { null } catch (x: Exception) { x.printStackTrace() null } } /** * Changes the password (which includes generating new login key). Does not require any particular * [state]. This is a long operation. On success, it changes (updates) [state] to [LoginState.LoggedIn] * with new data whatever it was before. Be aware of it. */ protected suspend fun changePasswordWithACO( aco: AccessControlObject, newPassword: String, params: PasswordDerivationParams = PasswordDerivationParams(), loginKeyStrength: Int = 2048, ): Boolean { return coroutineScope { // Get current nonce in parallel val deferredNonce = async { invoke(serverApi.slGetNonce) } // get login key in parallel val newLoginKey = BackgroundKeyGenerator.getKeyAsync(loginKeyStrength) // derive keys in main scope val keys = DerivedKeys.derive(newPassword, params) // new ACO payload: new login key, old data storage key and login val newSlp = SuperloginRestoreAccessPayload( aco.payload.login, newLoginKey.await(), aco.payload.dataStorageKey ) // new ACO with a new password key and payload (but the same secret!) val newAco = aco.updatePasswordKey(keys.loginAccessKey).updatePayload(newSlp) // trying to update val result = invoke( serverApi.slChangePasswordAndLogin, ChangePasswordArgs( aco.payload.login, SignedRecord.pack( aco.payload.loginPrivateKey, ChangePasswordPayload(newAco.packed, params, newLoginKey.await().publicKey, keys.loginId), deferredNonce.await() ) ) ) when (result) { is AuthenticationResult.Success -> { clientState = ClientState(result.loginName, result.loginToken, extractData(result.applicationData), aco.payload.dataStorageKey) true } else -> { warning { "Change password result: $result" } false } } } } /** * Change password for a logged-in user using its known password. It is a long operation. * * @param oldPassword existing password (re-request it from a user!) * @param newPassword new password. we do not check it, but it should be strong - check it on your end * for example with [net.sergeych.unikrypto.Passwords] tools * @param passwordDerivationParams at this point derivation parameters are always updated so it is possible * to set it to desired * @param loginKeyStrength login key is regenerated so its strength could be updated here * * @return true if the password has been successfully changed, false if the server didn't allow it. * * @throws InvalidPasswordError if the oldPassword is wrong */ suspend fun changePassword( oldPassword: String, newPassword: String, passwordDerivationParams: PasswordDerivationParams = PasswordDerivationParams(), loginKeyStrength: Int = 2048, ): Boolean { mustBeLoggedIn() val loginName = clientState?.loginName ?: throw SLInternalException("loginName should be defined here") val dp = invoke(serverApi.slRequestDerivationParams, loginName) val keys = DerivedKeys.derive(oldPassword, dp) val data = invoke(serverApi.slRequestACOByLoginName, RequestACOByLoginNameArgs(loginName, keys.loginId)) try { return AccessControlObject.unpackWithKey( data.packedACO, keys.loginAccessKey ) ?.let { changePasswordWithACO(it, newPassword, passwordDerivationParams, loginKeyStrength) } ?: false } catch (e: Exception) { when (e) { is Container.StructureError, is Container.DecryptionError, is EncryptedBinaryStorage.DecryptionFailed -> throw InvalidPasswordError() else -> throw e } } } companion object { inline operator fun invoke( t: Parsec3Transport, savedData: ClientState? = null, ): SuperloginClient { return SuperloginClient(t, savedData, typeOf()) } } }