working: registration with secret, login by token, logout, login by password.

This commit is contained in:
Sergey Chernov 2022-11-30 20:32:22 +01:00
parent 449de2e504
commit 318582cdb7
10 changed files with 301 additions and 128 deletions

View File

@ -43,7 +43,7 @@ kotlin {
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.3")
api("net.sergeych:unikrypto:1.2.1-SNAPSHOT")
api("net.sergeych:unikrypto:1.2.2-SNAPSHOT")
api("net.sergeych:parsec3:0.3.2-SNAPSHOT")
api("net.sergeych:boss-serialization-mp:0.2.4-SNAPSHOT")
3 }

View File

@ -1,10 +1,9 @@
package net.sergeych.superlogin
import kotlinx.serialization.Serializable
import net.sergeych.boss_serialization.BossDecoder
import net.sergeych.boss_serialization_mp.BossEncoder
import net.sergeych.unikrypto.Container
import net.sergeych.unikrypto.DecryptingKey
import net.sergeych.unikrypto.Safe58
import net.sergeych.unikrypto.SymmetricKey
import kotlin.reflect.KType
import kotlin.reflect.typeOf
@ -131,10 +130,14 @@ class AccessControlObject<T>(
*/
inline fun <reified T> unpackWithPasswordKey(packed: ByteArray, passwordKey: SymmetricKey): AccessControlObject<T>? =
Container.decrypt<Data<T>>(packed, passwordKey)?.let {
println(it)
AccessControlObject(typeOf<Data<T>>(), packed, passwordKey, it)
}
fun <T>unpackWithPasswordKey(packed: ByteArray, passwordKey: SymmetricKey,payloadType: KType): AccessControlObject<T>? =
Container.decryptAsBytes(packed, passwordKey)?.let {
AccessControlObject(payloadType, packed, passwordKey, BossDecoder.decodeFrom(payloadType,it))
}
/**
* Unpack and decrypt ACO with a [secret]. If you want to check [secret] integrity, use
* [RestoreKey.checkSecretIntegrity].

View File

@ -21,6 +21,7 @@ data class RegistrationArgs(
sealed class AuthenticationResult {
@Serializable
data class Success(
val loginName: String,
val loginToken: ByteArray,
val applicationData: ByteArray?
): AuthenticationResult()
@ -36,19 +37,32 @@ sealed class AuthenticationResult {
}
@Serializable
data class LoginArgs(
data class RequestLoginDataArgs(
val loginName: String,
val loginId: ByteArray,
val packedSignedRecord: ByteArray
)
@Serializable
data class RequestLoginDataResult(
val packedACO: ByteArray,
val nonce: ByteArray
)
@Serializable
data class LoginByPasswordPayload(
val loginName: String
)
class SuperloginServerApi<T: WithAdapter> : CommandHost<T>() {
val slRegister by command<RegistrationArgs,AuthenticationResult>()
val slGetNonce by command<Unit,ByteArray>()
val slRegister by command<ByteArray,AuthenticationResult>()
val slLogout by command<Unit,Unit>()
val slLoginByToken by command<ByteArray,AuthenticationResult>()
val slRequestDerivationParams by command<String,PasswordDerivationParams>()
val slRequestLoginData by command<RequestLoginDataArgs,RequestLoginDataResult>()
val slLoginByKey by command<ByteArray,AuthenticationResult>()
/**

View File

@ -11,6 +11,7 @@ import net.sergeych.parsec3.WithAdapter
import net.sergeych.superlogin.*
import net.sergeych.superlogin.server.SuperloginRestoreAccessPayload
import net.sergeych.unikrypto.HashAlgorithm
import net.sergeych.unikrypto.SignedRecord
import net.sergeych.unikrypto.SymmetricKey
import net.sergeych.unikrypto.digest
import kotlin.random.Random
@ -87,21 +88,25 @@ class Registration(
passwordKeys = DerivedKeys.derive(password, lastDerivationParams!! )
lastPasswordHash = newPasswordHash
}
val nonce = adapter.invokeCommand(api.slGetNonce)
val loginPrivateKey = deferredLoginKey.await()
val spl = SuperloginRestoreAccessPayload(login, loginPrivateKey, dataKey)
repeat(10) {
val (restoreKey, restoreData) = AccessControlObject.pack(passwordKeys!!.loginAccessKey, spl)
try {
val result = adapter.invokeCommand(
api.slRegister, RegistrationArgs(
val packedArgs = SignedRecord.pack(
loginPrivateKey,
RegistrationArgs(
login,
passwordKeys!!.loginId,
deferredLoginKey.await().publicKey,
lastDerivationParams!!,
restoreKey.restoreId, restoreData,
extraData
),
nonce
)
)
val result = adapter.invokeCommand(api.slRegister, packedArgs)
when (result) {
AuthenticationResult.RestoreIdUnavailable -> {
// rare situation but still possible: just repack the ACO

View File

@ -17,8 +17,9 @@ import net.sergeych.parsec3.Adapter
import net.sergeych.parsec3.CommandDescriptor
import net.sergeych.parsec3.Parsec3Transport
import net.sergeych.parsec3.WithAdapter
import net.sergeych.superlogin.AuthenticationResult
import net.sergeych.superlogin.SuperloginServerApi
import net.sergeych.superlogin.*
import net.sergeych.superlogin.server.SuperloginRestoreAccessPayload
import net.sergeych.unikrypto.SignedRecord
import kotlin.reflect.KType
import kotlin.reflect.typeOf
@ -32,6 +33,7 @@ import kotlin.reflect.typeOf
*/
@Serializable
data class SuperloginData<T>(
val loginName: String,
val loginToken: ByteArray? = null,
val data: T? = null,
)
@ -60,14 +62,6 @@ class SuperloginClient<D, S : WithAdapter>(
// do actual disconnect work
_cflow.value = false
_state.value = LoginState.LoggedOut
if (!adapterReady.isActive) {
adapterReady.cancel()
adapterReady = CompletableDeferred()
}
globalLaunch {
transport.adapter().invokeCommand(serverApi.slLogout)
adapterReady.complete(Unit)
}
} else {
val v = _state.value
if (v !is LoginState.LoggedIn<*> || v.loginData != value) {
@ -94,10 +88,12 @@ class SuperloginClient<D, S : WithAdapter>(
} while (true)
}
suspend fun <A, R> call(ca: CommandDescriptor<A, R>, args: A ): R = adapter().invokeCommand(ca, args)
suspend fun <A, R> call(ca: CommandDescriptor<A, R>, args: A): R = adapter().invokeCommand(ca, args)
suspend fun <R> call(ca: CommandDescriptor<Unit, R>): R = adapter().invokeCommand(ca)
private suspend fun <A, R> invoke(ca: CommandDescriptor<A, R>, args: A ): R = transport.adapter().invokeCommand(ca, args)
private suspend fun <A, R> invoke(ca: CommandDescriptor<A, R>, args: A): R =
transport.adapter().invokeCommand(ca, args)
private suspend fun <R> invoke(ca: CommandDescriptor<Unit, R>): R = transport.adapter().invokeCommand(ca)
private var jobs = listOf<Job>()
@ -110,7 +106,7 @@ class SuperloginClient<D, S : WithAdapter>(
val ar = transport.adapter().invokeCommand(serverApi.slLoginByToken, token)
slData = if (ar is AuthenticationResult.Success) {
val data: D? = ar.applicationData?.let { BossDecoder.decodeFrom(dataType, it) }
SuperloginData(ar.loginToken, data)
SuperloginData(ar.loginName, ar.loginToken, data)
} else {
null
}
@ -170,13 +166,12 @@ class SuperloginClient<D, S : WithAdapter>(
return rn.registerWithData(loginName, password, extraData = BossEncoder.encode(dataType, data))
.also { rr ->
if (rr is Registration.Result.Success) {
slData = SuperloginData(rr.loginToken, extractData(rr.encodedData))
slData = SuperloginData(loginName, rr.loginToken, extractData(rr.encodedData))
}
}
}
private fun extractData(rr: ByteArray?): D?
= rr?.let { BossDecoder.decodeFrom(dataType, it) }
private fun extractData(rr: ByteArray?): D? = rr?.let { BossDecoder.decodeFrom(dataType, it) }
private fun mustBeLoggedOut() {
if (isLoggedIn)
@ -188,8 +183,9 @@ class SuperloginClient<D, S : WithAdapter>(
throw IllegalStateException("please log in first")
}
fun logout() {
suspend fun logout() {
mustBeLoggedIn()
invoke(serverApi.slLogout)
slData = null
}
@ -202,15 +198,54 @@ class SuperloginClient<D, S : WithAdapter>(
*/
suspend fun loginByToken(token: ByteArray): SuperloginData<D>? {
mustBeLoggedOut()
val r = invoke(serverApi.slLoginByToken,token)
return when(r) {
val r = invoke(serverApi.slLoginByToken, token)
return when (r) {
AuthenticationResult.LoginIdUnavailable -> TODO()
AuthenticationResult.LoginUnavailable -> null
AuthenticationResult.RestoreIdUnavailable -> TODO()
is AuthenticationResult.Success -> SuperloginData(
r.loginName,
r.loginToken,
extractData(r.applicationData)
).also {
slData = it
}
}
}
suspend fun loginByPassword(loginName: String, password: String): SuperloginData<D>? {
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.slRequestLoginData,
RequestLoginDataArgs(loginName, keys.loginId)
).let { loginRequest ->
try {
AccessControlObject.unpackWithPasswordKey<SuperloginRestoreAccessPayload>(
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) {
SuperloginData(loginName, result.loginToken, extractData(result.applicationData))
.also { slData = it }
} else null
}
}
catch (t: Throwable) {
t.printStackTrace()
throw t
}
}
}

View File

@ -0,0 +1,34 @@
package net.sergeych.superlogin
import net.sergeych.unikrypto.HashAlgorithm
import net.sergeych.unikrypto.digest
/**
* Primitive to simplify create and compare salted byte arrays
*/
data class Salted(val salt: ByteArray,val data: ByteArray) {
val salted = HashAlgorithm.SHA3_256.digest(salt, data)
fun matches(other: ByteArray): Boolean {
return Salted(salt, other) == this
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as Salted
if (!salt.contentEquals(other.salt)) return false
if (!data.contentEquals(other.data)) return false
return true
}
override fun hashCode(): Int {
var result = salt.contentHashCode()
result = 31 * result + data.contentHashCode()
return result
}
}

View File

@ -1,42 +0,0 @@
package net.sergeych.superlogin.server
import net.sergeych.superlogin.AuthenticationResult
import net.sergeych.superlogin.RegistrationArgs
/**
* Set of procedures the server implementing superlogin protocol should provide
*/
interface SLServerTraits {
/**
* Register specified user. The server should check that:
*
* - [RegistrationArgs.loginName] is not used, otherwise return [AuthenticationResult.LoginUnavailable]
* - [RegistrationArgs.loginId] is not used, otherwise return [AuthenticationResult.LoginIdUnavailable]
* - [RegistrationArgs.restoreId] is not used or return [AuthenticationResult.RestoreIdUnavailable]
*
* Then it should save permanently data from `registrationArgs` in a way tha allow fast search (indexed,
* usually) by `loginName`, `loginId` and `restoreId`, and return [AuthenticationResult.Success] with
* newly generated random bytes string `loginToken` that would optionally simplify logging in.
*
* If the implementation does not provide login token, it should still provide random bytes string
* to maintain hight security level of the serivce.
*/
suspend fun register(registrationArgs: RegistrationArgs): AuthenticationResult
/**
* Logging out procedure does not need any extra logic unless reuired by application
* server software. Default implementation does nothing.
*/
suspend fun logout() {}
/**
* Try to log in using an authentication token, which normally is returned in
* [AuthenticationResult.Success.loginToken]. If the server implementation
* does not support login token, don't implement it, use the default implementation.
*
* Otherwise, implement login by token and return either [AuthenticationResult.Success]
* or [AuthenticationResult.LoginUnavailable]. So not return anything else.
*/
suspend fun loginByToken(token: ByteArray): AuthenticationResult
= AuthenticationResult.LoginUnavailable
}

View File

@ -1,18 +1,90 @@
package net.sergeych.superlogin.server
import net.sergeych.boss_serialization_mp.decodeBoss
import net.sergeych.parsec3.WithAdapter
import net.sergeych.superlogin.AuthenticationResult
import net.sergeych.superlogin.BackgroundKeyGenerator
import net.sergeych.superlogin.PasswordDerivationParams
import net.sergeych.superlogin.RegistrationArgs
import net.sergeych.superlogin.client.SuperloginData
import net.sergeych.unikrypto.PublicKey
/**
* The superlogin server session. Please use only [loginName] and [loginToken], the rest could be
* The superlogin server session. Please use only [currentLoginName] and [currentLoginToken], the rest could be
* a subject to change.
*/
open class SLServerSession<T>: WithAdapter() {
var slData: SuperloginData<T>? = null
abstract class SLServerSession<T> : WithAdapter() {
val userData: T? get() = slData?.let { it.data }
val nonce = BackgroundKeyGenerator.randomBytes(32)
val loginToken get() = slData?.loginToken
var superloginData: SuperloginData<T>? = null
var loginName: String? = null
val userData: T? get() = superloginData?.data
val currentLoginToken get() = superloginData?.loginToken
val currentLoginName: String? get() = superloginData?.loginName
fun requireLoggedIn() {
if (currentLoginName == null) throw IllegalStateException("must be logged in")
}
fun requireLoggedOut() {
if (currentLoginName != null) throw IllegalStateException("must be logged out")
}
/**
* Register specified user. The server should check that:
*
* - [RegistrationArgs.loginName] is not used, otherwise return [AuthenticationResult.LoginUnavailable]
* - [RegistrationArgs.loginId] is not used, otherwise return [AuthenticationResult.LoginIdUnavailable]
* - [RegistrationArgs.restoreId] is not used or return [AuthenticationResult.RestoreIdUnavailable]
*
* Then it should save permanently data from `registrationArgs` in a way tha allow fast search (indexed,
* usually) by `loginName`, `loginId` and `restoreId`, and return [AuthenticationResult.Success] with
* newly generated random bytes string `loginToken` that would optionally simplify logging in.
*
* If the implementation does not provide login token, it should still provide random bytes string
* to maintain hight security level of the serivce.
*/
abstract suspend fun register(registrationArgs: RegistrationArgs): AuthenticationResult
/**
* Logging out procedure does not need any extra logic unless reuired by application
* server software. Default implementation does nothing.
*/
open suspend fun logout() {}
/**
* Try to log in using an authentication token, which normally is returned in
* [AuthenticationResult.Success.loginToken]. If the server implementation
* does not support login token, don't implement it, use the default implementation.
*
* Otherwise, implement login by token and return either [AuthenticationResult.Success]
* or [AuthenticationResult.LoginUnavailable]. So not return anything else.
*/
open suspend fun loginByToken(token: ByteArray): AuthenticationResult = AuthenticationResult.LoginUnavailable
/**
* Retreive exact password derivation params as were stored by registration.
* @return derivation params for the login name or null if the name is not known
*/
abstract suspend fun requestDerivationParams(login: String): PasswordDerivationParams?
/**
* Override this method, check loginId to match loginName, and of ot os ok, return packed ACO
* @return packed ACO or null if loginName is wrong, or loginId does not match it.
*/
abstract suspend fun requestLoginData(loginName: String,loginId: ByteArray): ByteArray?
/**
* Implementation must: find the actual user by loginName and check the publicKey is valid (for example
* matches stored key id in the database, and return
* @return [AuthenticationResult.Success]
*/
abstract suspend fun loginByKey(loginName: String,publicKey: PublicKey): AuthenticationResult
}
inline fun <reified D, T : SLServerSession<D>> T.setSlData(it: AuthenticationResult.Success) {
superloginData = SuperloginData(it.loginName, it.loginToken, it.applicationData?.decodeBoss<D>())
}

View File

@ -1,31 +1,66 @@
package net.sergeych.superlogin.server
import net.sergeych.boss_serialization_mp.decodeBoss
import net.sergeych.parsec3.AdapterBuilder
import net.sergeych.parsec3.CommandHost
import net.sergeych.parsec3.WithAdapter
import net.sergeych.superlogin.AuthenticationResult
import net.sergeych.superlogin.SuperloginServerApi
import net.sergeych.superlogin.client.SuperloginData
import net.sergeych.superlogin.*
import net.sergeych.unikrypto.SignedRecord
import kotlin.random.Random
inline fun <reified D, T : SLServerSession<D>, H : CommandHost<T>> AdapterBuilder<T, H>.superloginServer(
traits: SLServerTraits,
) {
inline fun <reified D, T : SLServerSession<D>, H : CommandHost<T>> AdapterBuilder<T, H>.superloginServer() {
val a2 = SuperloginServerApi<WithAdapter>()
on(a2.slRegister) { ra ->
traits.register(ra).also { rr ->
if( rr is AuthenticationResult.Success)
slData = SuperloginData<D>(rr.loginToken, rr.applicationData?.let { it.decodeBoss<D>()})
loginName = ra.loginName
on(a2.slGetNonce) { nonce }
on(a2.slRegister) { packed ->
requireLoggedOut()
val ra = SignedRecord.unpack(packed) { sr ->
if( !(sr.nonce contentEquals nonce) )
throw IllegalArgumentException("wrong signed record nonce")
}.decode<RegistrationArgs>()
register(ra).also { rr ->
if( rr is AuthenticationResult.Success) {
setSlData(rr)
}
}
}
on(a2.slLogout) {
println("--- logged out ---")
slData = null
loginName = null
traits.logout()
superloginData = null
logout()
}
on(a2.slLoginByToken) { token ->
traits.loginByToken(token)
requireLoggedOut()
loginByToken(token).also {
if( it is AuthenticationResult.Success)
setSlData(it)
}
}
on(a2.slRequestDerivationParams) { name ->
// If we don't know this login, we still provide derivatino params to
// slow down login scanning
requestDerivationParams(name) ?: PasswordDerivationParams()
}
on(a2.slRequestLoginData) { args ->
requestLoginData(args.loginName,args.loginId)?.let {
RequestLoginDataResult(it, nonce)
} ?: RequestLoginDataResult(Random.nextBytes(117), nonce)
}
on(a2.slLoginByKey) { packedSR ->
try {
// Check key
val sr = SignedRecord.unpack(packedSR) {
if (!(it.nonce contentEquals nonce)) throw IllegalArgumentException()
}
val loginName: String = sr.decode<LoginByPasswordPayload>().loginName
loginByKey(loginName, sr.publicKey).also {
if( it is AuthenticationResult.Success)
setSlData(it)
}
}
catch(x: Exception) {
// most likely, wrong nonce, less probable bad signature
AuthenticationResult.LoginUnavailable
}
}
}

View File

@ -9,40 +9,32 @@ import net.sergeych.parsec3.Parsec3WSClient
import net.sergeych.parsec3.WithAdapter
import net.sergeych.parsec3.parsec3TransportServer
import net.sergeych.superlogin.AuthenticationResult
import net.sergeych.superlogin.PasswordDerivationParams
import net.sergeych.superlogin.RegistrationArgs
import net.sergeych.superlogin.client.LoginState
import net.sergeych.superlogin.client.Registration
import net.sergeych.superlogin.client.SuperloginClient
import net.sergeych.superlogin.server.SLServerSession
import net.sergeych.superlogin.server.SLServerTraits
import net.sergeych.superlogin.server.superloginServer
import net.sergeych.unikrypto.PublicKey
import superlogin.assertThrowsAsync
import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.*
data class TestSession(var buzz: String = "BuZZ") : SLServerSession<TestData>()
class TestApiServer<T : WithAdapter> : CommandHost<T>() {
val loginName by command<Unit, String?>()
}
object TestServerTraits : SLServerTraits {
data class TestSession(var buzz: String = "BuZZ") : SLServerSession<TestData>() {
val byLogin = mutableMapOf<String, RegistrationArgs>()
val byLoginId = mutableMapOf<List<Byte>, RegistrationArgs>()
val byRestoreId = mutableMapOf<List<Byte>, RegistrationArgs>()
val byToken = mutableMapOf<List<Byte>, RegistrationArgs>()
val tokens = mutableMapOf<String, ByteArray>()
override suspend fun register(ra: RegistrationArgs): AuthenticationResult {
println("ra: ${ra.loginName}")
println("ra: ${ra.loginName} : $currentLoginName : $superloginData")
return when {
ra.loginName in byLogin -> {
AuthenticationResult.LoginUnavailable
}
ra.loginId.toList() in byLoginId -> AuthenticationResult.LoginIdUnavailable
ra.restoreId.toList() in byRestoreId -> AuthenticationResult.RestoreIdUnavailable
else -> {
@ -51,35 +43,45 @@ object TestServerTraits : SLServerTraits {
byLoginId[ra.loginId.toList()] = ra
val token = Random.Default.nextBytes(32)
byToken[token.toList()] = ra
AuthenticationResult.Success(token, ra.extraData)
tokens[ra.loginName] = token
AuthenticationResult.Success(ra.loginName, token, ra.extraData)
}
}
}
override suspend fun loginByToken(token: ByteArray): AuthenticationResult {
return byToken[token.toList()]?.let {
AuthenticationResult.Success(token, it.extraData)
} ?: AuthenticationResult.LoginUnavailable
AuthenticationResult.Success(it.loginName, token, it.extraData)
}
?: AuthenticationResult.LoginUnavailable
}
override suspend fun requestDerivationParams(login: String): PasswordDerivationParams? =
byLogin[login]?.derivationParams
override suspend fun requestLoginData(loginName: String, loginId: ByteArray): ByteArray? {
return byLogin[loginName]?.restoreData
}
override suspend fun loginByKey(loginName: String, publicKey: PublicKey): AuthenticationResult {
val ra = byLogin[loginName]
return if (ra != null && ra.loginPublicKey.id == publicKey.id)
AuthenticationResult.Success(ra.loginName, tokens[loginName]!!, ra.extraData)
else AuthenticationResult.LoginUnavailable
}
}
class TestApiServer<T : WithAdapter> : CommandHost<T>() {
val loginName by command<Unit, String?>()
}
@Serializable
data class TestData(
val foo: String,
)
//fun <S: SLServerSession<*>,A: CommandHost<S>> Application.superloginServer(
// traits: SLServerTraits,
// api: A,
// f: AdapterBuilder<S,A>.()->Unit) {
// parsec3TransportServer(api) {
// superloginServer(traits)
// f()
// }
//}
internal class WsServerKtTest {
@ -90,9 +92,10 @@ internal class WsServerKtTest {
parsec3TransportServer(TestApiServer<SLServerSession<TestData>>()) {
// superloginServer(TestServerTraits,TestApiServer<SLServerSession<TestData>>()) {
newSession { TestSession() }
superloginServer(TestServerTraits)
superloginServer()
on(api.loginName) {
loginName
println("login name called. now we have $currentLoginName : $superloginData")
currentLoginName
}
}
}.start(wait = false)
@ -125,10 +128,24 @@ internal class WsServerKtTest {
rt = slc.register("foo", "passwd", TestData("nobar"))
assertIs<Registration.Result.InvalidLogin>(rt)
assertIs<LoginState.LoggedOut>(slc.state.value)
assertEquals(null, slc.call(api.loginName))
var ar = slc.loginByToken(token)
assertNotNull(ar)
assertEquals("bar!", ar.data?.foo)
assertTrue { slc.isLoggedIn }
assertEquals("foo", slc.call(api.loginName))
//
assertThrowsAsync<IllegalStateException> { slc.loginByToken(token) }
slc.logout()
assertNull(slc.loginByPassword("foo", "wrong"))
ar = slc.loginByPassword("foo", "passwd")
println(ar)
assertNotNull(ar)
assertEquals("bar!", ar.data?.foo)
assertTrue { slc.isLoggedIn }
assertEquals("foo", slc.call(api.loginName))
}