Updated on 2026-08-14
This commit is contained in:
parent
c16e04089d
commit
7fdfe487b1
11 changed files with 683 additions and 29 deletions
|
|
@ -24,12 +24,15 @@ import com.tangem.lib.auth.nonce.internal.DisabledAuthNonceDecryptor
|
|||
import com.tangem.lib.auth.session.DeviceRegistrar
|
||||
import com.tangem.lib.auth.session.SessionTokenRefresher
|
||||
import com.tangem.lib.auth.session.SessionTokensStore
|
||||
import com.tangem.lib.auth.session.WalletRegistrar
|
||||
import com.tangem.lib.auth.session.internal.AuthErrorConverter
|
||||
import com.tangem.lib.auth.session.internal.DefaultDeviceRegistrar
|
||||
import com.tangem.lib.auth.session.internal.DefaultSessionTokenRefresher
|
||||
import com.tangem.lib.auth.session.internal.DefaultSessionTokensStore
|
||||
import com.tangem.lib.auth.session.internal.DefaultWalletRegistrar
|
||||
import com.tangem.lib.auth.session.internal.DisabledDeviceRegistrar
|
||||
import com.tangem.lib.auth.session.internal.DisabledSessionTokenRefresher
|
||||
import com.tangem.lib.auth.session.internal.DisabledWalletRegistrar
|
||||
import com.tangem.lib.auth.session.internal.DisabledSessionTokensStore
|
||||
import com.tangem.lib.auth.session.internal.SignedRequestPayload
|
||||
import com.tangem.sdk.storage.AndroidSecureStorageV2
|
||||
|
|
@ -195,6 +198,34 @@ internal object AuthModule {
|
|||
)
|
||||
}
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideWalletRegistrar(
|
||||
authFeatureToggles: AuthFeatureToggles,
|
||||
authApi: AuthApi,
|
||||
store: SessionTokensStore,
|
||||
deviceKeyManager: DeviceKeyManager,
|
||||
nonceDecryptor: AuthNonceDecryptor,
|
||||
signedRequestPayload: SignedRequestPayload,
|
||||
errorConverter: AuthErrorConverter,
|
||||
appPreferencesStore: AppPreferencesStore,
|
||||
dispatchers: CoroutineDispatcherProvider,
|
||||
): WalletRegistrar {
|
||||
if (!authFeatureToggles.isBackendAuthenticationEnabled) return DisabledWalletRegistrar
|
||||
|
||||
return DefaultWalletRegistrar(
|
||||
authApi = authApi,
|
||||
store = store,
|
||||
deviceKeyManager = deviceKeyManager,
|
||||
nonceDecryptor = nonceDecryptor,
|
||||
signedRequestPayload = signedRequestPayload,
|
||||
errorConverter = errorConverter,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@SessionAuthInterceptor
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
package com.tangem.lib.auth.session
|
||||
|
||||
import arrow.core.Either
|
||||
|
||||
/**
|
||||
* Binds a wallet to the already-registered device with the Tangem Auth Service
|
||||
* (`POST /api/v1/auth/wallet`), proving wallet (and, for cold cards, card) ownership over a
|
||||
* server-issued wallet nonce.
|
||||
*
|
||||
* Idempotent per wallet: once a `walletId` is registered it is remembered, and subsequent calls
|
||||
* for it short-circuit without network traffic. Layered on top of device registration — requires a
|
||||
* valid DPoP-bound session (see `DeviceRegistrar` / `SessionTokenRefresher`).
|
||||
*
|
||||
* Signing is delegated to the caller via [WalletSigner]: the registrar owns the nonce request,
|
||||
* decryption, request assembly, POST and persistence, but the NFC/biometric signature itself is
|
||||
* produced in the app layer (the registrar has no Card/hot SDK dependency). The nonce must be
|
||||
* fetched before signing (the signature is over the nonce), so the registrar fetches it and hands
|
||||
* the deciphered bytes to the signer.
|
||||
*
|
||||
* Tokens returned by `/wallet` are written to `SessionTokensStore`, not surfaced to callers — the
|
||||
* result type carries only success/failure so callers can log transient errors.
|
||||
*/
|
||||
interface WalletRegistrar {
|
||||
|
||||
/**
|
||||
* Registers the wallet identified by [walletId] (Base64 `UserWalletId`), using [signer] to
|
||||
* produce the signature material over the deciphered wallet nonce.
|
||||
*/
|
||||
suspend fun register(walletId: String, signer: WalletSigner): Either<WalletRegistrationError, Unit>
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package com.tangem.lib.auth.session
|
||||
|
||||
/**
|
||||
* Typed failure mode of `WalletRegistrar.register()`. Mirrors [DeviceRegistrationError] but covers
|
||||
* the wallet-binding paths (`/nonce/wallet` + `/wallet`).
|
||||
*/
|
||||
sealed class WalletRegistrationError {
|
||||
|
||||
/** API-level error from `/nonce/wallet` or `/wallet`. Transient unless [cause] says otherwise. */
|
||||
data class Api(val cause: AuthError) : WalletRegistrationError()
|
||||
|
||||
/** Device key is not provisioned in Keystore (registration cannot proceed without one). */
|
||||
data object DeviceKeyUnavailable : WalletRegistrationError()
|
||||
|
||||
/** RSA/OAEP decryption of the server-issued wallet nonce failed. */
|
||||
data class NonceDecryptionFailed(val cause: Throwable) : WalletRegistrationError()
|
||||
|
||||
/**
|
||||
* Producing the wallet/card signature failed — Card SDK / hot SDK error, or the user cancelled
|
||||
* the NFC tap / biometric prompt.
|
||||
*/
|
||||
data class SigningFailed(val cause: Throwable) : WalletRegistrationError()
|
||||
|
||||
/**
|
||||
* Persisting the reissued tokens or the registered-wallet marker failed (DataStore I/O). The
|
||||
* marker stays unset, so the next attempt retries cleanly.
|
||||
*/
|
||||
data class PersistenceFailed(val cause: Throwable) : WalletRegistrationError()
|
||||
|
||||
/** Registrar is disabled via `AND_15438_BACKEND_AUTHENTICATION_ENABLED` feature toggle. */
|
||||
data object Disabled : WalletRegistrationError()
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package com.tangem.lib.auth.session
|
||||
|
||||
/**
|
||||
* Raw signature material a caller produces for a single wallet over the deciphered wallet nonce.
|
||||
* Produced by a [WalletSigner] (NFC card attestation for COLD wallets, hot SDK signing for MOBILE).
|
||||
*
|
||||
* `WalletRegistrar` Base64-encodes these bytes before building the network request, so all values
|
||||
* here are raw bytes — the caller must not pre-encode them.
|
||||
*
|
||||
* @property walletSignature 65-byte secp256k1 RSV signature (v = recId + 27) over
|
||||
* `sha256(nonceBytes || walletSignatureSalt)`. The server recovers the wallet public key from it.
|
||||
* @property walletSignatureSalt salt mixed into the wallet-signature hash.
|
||||
* @property cardSignature 65-byte secp256k1 RSV signature over
|
||||
* `sha256(walletPublicKey || nonceBytes || cardSignatureSalt || walletStatus)`. COLD wallets only;
|
||||
* `null` for MOBILE (hot) wallets.
|
||||
* @property cardSignatureSalt salt mixed into the card-signature hash. COLD wallets only.
|
||||
* @property walletStatusByte single byte describing wallet provenance on the card
|
||||
* (`0x82` = generated on card, `0xC2` = SEED imported). COLD wallets only.
|
||||
*/
|
||||
data class WalletSignatureBundle(
|
||||
val walletSignature: ByteArray,
|
||||
val walletSignatureSalt: ByteArray,
|
||||
val cardSignature: ByteArray?,
|
||||
val cardSignatureSalt: ByteArray?,
|
||||
val walletStatusByte: Byte?,
|
||||
) {
|
||||
// ByteArray uses referential equality in the data-class-generated equals/hashCode; override with
|
||||
// content comparison so two bundles with identical bytes compare equal.
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is WalletSignatureBundle) return false
|
||||
|
||||
return walletSignature.contentEquals(other.walletSignature) &&
|
||||
walletSignatureSalt.contentEquals(other.walletSignatureSalt) &&
|
||||
cardSignature.contentEquals(other.cardSignature) &&
|
||||
cardSignatureSalt.contentEquals(other.cardSignatureSalt) &&
|
||||
walletStatusByte == other.walletStatusByte
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = walletSignature.contentHashCode()
|
||||
result = 31 * result + walletSignatureSalt.contentHashCode()
|
||||
result = 31 * result + (cardSignature?.contentHashCode() ?: 0)
|
||||
result = 31 * result + (cardSignatureSalt?.contentHashCode() ?: 0)
|
||||
result = 31 * result + (walletStatusByte?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Produces a [WalletSignatureBundle] for the wallet being registered, given the deciphered wallet
|
||||
* nonce bytes. Implemented in the app/data layer where the Card SDK / hot wallet SDK live; the
|
||||
* `WalletRegistrar` stays free of SDK dependencies.
|
||||
*/
|
||||
fun interface WalletSigner {
|
||||
suspend fun sign(nonceBytes: ByteArray): WalletSignatureBundle
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
package com.tangem.lib.auth.session.internal
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.raise.Raise
|
||||
import arrow.core.raise.either
|
||||
import com.tangem.datasource.api.auth.AuthApi
|
||||
import com.tangem.datasource.api.auth.models.request.NonceApiRequest
|
||||
import com.tangem.datasource.api.auth.models.request.WalletRegistrationRequest
|
||||
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||
import com.tangem.datasource.local.preferences.utils.getSyncOrDefault
|
||||
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
||||
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
|
||||
import com.tangem.lib.auth.session.AuthError
|
||||
import com.tangem.lib.auth.session.WalletRegistrar
|
||||
import com.tangem.lib.auth.session.WalletRegistrationError
|
||||
import com.tangem.lib.auth.session.WalletSigner
|
||||
import com.tangem.lib.auth.session.SessionTokensStore
|
||||
import com.tangem.utils.coroutines.CoroutineDispatcherProvider
|
||||
import com.tangem.utils.logging.TangemLogger
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@Suppress("LongParameterList")
|
||||
internal class DefaultWalletRegistrar(
|
||||
private val authApi: AuthApi,
|
||||
private val store: SessionTokensStore,
|
||||
private val deviceKeyManager: DeviceKeyManager,
|
||||
private val nonceDecryptor: AuthNonceDecryptor,
|
||||
private val signedRequestPayload: SignedRequestPayload,
|
||||
private val errorConverter: AuthErrorConverter,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val dispatchers: CoroutineDispatcherProvider,
|
||||
) : WalletRegistrar {
|
||||
|
||||
// Per-wallet mutex: serialises concurrent attempts for the SAME wallet (so its nonce isn't
|
||||
// consumed twice) while letting different wallets register in parallel. The registered-wallet
|
||||
// set is mutated atomically inside a single DataStore transaction (see [markRegistered]), so it
|
||||
// needs no cross-wallet lock.
|
||||
private val mutexes = ConcurrentHashMap<String, Mutex>()
|
||||
|
||||
override suspend fun register(walletId: String, signer: WalletSigner): Either<WalletRegistrationError, Unit> =
|
||||
withContext(dispatchers.io) {
|
||||
getMutex(walletId).withLock { runRegister(walletId, signer) }
|
||||
}
|
||||
|
||||
private fun getMutex(walletId: String): Mutex = mutexes.computeIfAbsent(walletId) { Mutex() }
|
||||
|
||||
private suspend fun runRegister(walletId: String, signer: WalletSigner): Either<WalletRegistrationError, Unit> =
|
||||
either {
|
||||
val isAlreadyRegistered = try {
|
||||
walletId in registeredWalletIds()
|
||||
} catch (e: Exception) {
|
||||
TangemLogger.e("Failed to read registered wallet ids", e)
|
||||
raise(WalletRegistrationError.PersistenceFailed(e))
|
||||
}
|
||||
if (isAlreadyRegistered) {
|
||||
TangemLogger.i("Wallet already registered — skipping /wallet")
|
||||
return@either
|
||||
}
|
||||
|
||||
TangemLogger.i("Starting wallet registration")
|
||||
|
||||
val devicePublicKey = deviceKeyManager.getPublicKey().getOrNull()
|
||||
?: raise(WalletRegistrationError.DeviceKeyUnavailable)
|
||||
val devicePublicKeyBase64 = devicePublicKey.toBase64NoWrap()
|
||||
|
||||
val nonceResponse = authApi.requestWalletNonce(NonceApiRequest(devicePublicKey = devicePublicKeyBase64))
|
||||
val cipheredNonce = when (nonceResponse) {
|
||||
is ApiResponse.Success -> nonceResponse.data.cipheredNonce
|
||||
is ApiResponse.Error -> {
|
||||
val authError = errorConverter.convert(nonceResponse.cause)
|
||||
TangemLogger.e("/nonce/wallet request failed: $authError")
|
||||
raise(WalletRegistrationError.Api(authError))
|
||||
}
|
||||
}
|
||||
|
||||
val nonce = try {
|
||||
nonceDecryptor.decryptNonce(cipheredNonce)
|
||||
} catch (e: Exception) {
|
||||
TangemLogger.e("Failed to decrypt wallet nonce", e)
|
||||
raise(WalletRegistrationError.NonceDecryptionFailed(e))
|
||||
}
|
||||
|
||||
val bundle = try {
|
||||
signer.sign(nonceBytes = nonce.toByteArray(Charsets.UTF_8))
|
||||
} catch (e: Exception) {
|
||||
TangemLogger.e("Failed to sign wallet-registration payload", e)
|
||||
raise(WalletRegistrationError.SigningFailed(e))
|
||||
}
|
||||
|
||||
val request = WalletRegistrationRequest(
|
||||
nonce = nonce,
|
||||
walletId = walletId,
|
||||
walletSignature = bundle.walletSignature.toBase64NoWrap(),
|
||||
walletSignatureSalt = bundle.walletSignatureSalt.toBase64NoWrap(),
|
||||
cardSignature = bundle.cardSignature?.toBase64NoWrap(),
|
||||
cardSignatureSalt = bundle.cardSignatureSalt?.toBase64NoWrap(),
|
||||
walletStatus = bundle.walletStatusByte?.let { byteArrayOf(it).toBase64NoWrap() },
|
||||
attestationToken = null,
|
||||
metadata = signedRequestPayload.deviceMetadata,
|
||||
)
|
||||
|
||||
handleRegisterResponse(walletId = walletId, response = authApi.registerWallet(request))
|
||||
}
|
||||
|
||||
private suspend fun Raise<WalletRegistrationError>.handleRegisterResponse(
|
||||
walletId: String,
|
||||
response: ApiResponse<TokenApiResponse>,
|
||||
) {
|
||||
when (response) {
|
||||
is ApiResponse.Success -> {
|
||||
val tokens = SessionTokensConverter.convertBack(response.data)
|
||||
try {
|
||||
// Keep both writes inside one catch — if the marker write fails, the wallet
|
||||
// stays unregistered locally and the next attempt retries cleanly.
|
||||
store.save(tokens)
|
||||
markRegistered(walletId)
|
||||
} catch (e: Exception) {
|
||||
TangemLogger.e("Failed to persist wallet-registration tokens / marker", e)
|
||||
raise(WalletRegistrationError.PersistenceFailed(e))
|
||||
}
|
||||
TangemLogger.i("Wallet registered successfully")
|
||||
}
|
||||
is ApiResponse.Error -> {
|
||||
val authError = errorConverter.convert(response.cause)
|
||||
if (authError is AuthError.Conflict) {
|
||||
// Wallet is already registered server-side (e.g. local marker was lost on
|
||||
// reinstall). Persist the marker to stop retrying.
|
||||
TangemLogger.i("Wallet already registered server-side (409) — marking as registered")
|
||||
try {
|
||||
markRegistered(walletId)
|
||||
} catch (e: Exception) {
|
||||
TangemLogger.e("Failed to persist wallet-registration marker after 409", e)
|
||||
raise(WalletRegistrationError.PersistenceFailed(e))
|
||||
}
|
||||
return
|
||||
}
|
||||
TangemLogger.e("/wallet request failed: $authError")
|
||||
raise(WalletRegistrationError.Api(authError))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun registeredWalletIds(): Set<String> = appPreferencesStore.getSyncOrDefault(
|
||||
key = PreferencesKeys.REGISTERED_WALLET_IDS_KEY,
|
||||
default = emptySet(),
|
||||
)
|
||||
|
||||
private suspend fun markRegistered(walletId: String) {
|
||||
// Atomic read-modify-write inside a single DataStore transaction — DataStore serialises
|
||||
// these, so concurrent registrations of different wallets can't lose set entries.
|
||||
appPreferencesStore.editData { preferences ->
|
||||
val current = preferences.getOrDefault(PreferencesKeys.REGISTERED_WALLET_IDS_KEY, emptySet())
|
||||
preferences[PreferencesKeys.REGISTERED_WALLET_IDS_KEY] = current + walletId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package com.tangem.lib.auth.session.internal
|
||||
|
||||
import arrow.core.Either
|
||||
import arrow.core.left
|
||||
import com.tangem.lib.auth.session.WalletRegistrar
|
||||
import com.tangem.lib.auth.session.WalletRegistrationError
|
||||
import com.tangem.lib.auth.session.WalletSigner
|
||||
import com.tangem.utils.annotations.RemoveWithToggle
|
||||
|
||||
@RemoveWithToggle("AND_15438_BACKEND_AUTHENTICATION_ENABLED")
|
||||
internal object DisabledWalletRegistrar : WalletRegistrar {
|
||||
|
||||
override suspend fun register(walletId: String, signer: WalletSigner): Either<WalletRegistrationError, Unit> {
|
||||
return WalletRegistrationError.Disabled.left()
|
||||
}
|
||||
}
|
||||
|
|
@ -47,9 +47,11 @@ internal class SignedRequestPayload @Inject constructor(
|
|||
)
|
||||
|
||||
/**
|
||||
* Stable, newline-separated representation of the signed payload. Backend treats the bytes
|
||||
* opaquely; must stay aligned with the server-side canonicalisation. Field order matches the
|
||||
* declaration order of [RegisterPayload] / [AuthenticationPayload] and [DeviceMetadata].
|
||||
* Stable, colon-separated representation of the signed payload. Must stay byte-for-byte aligned
|
||||
* with the server-side canonicalisation (`RegistrationService` / `AuthenticationService`):
|
||||
* fields joined with `:` in this exact order, with a missing `attestationToken` collapsing to
|
||||
* an empty segment, and no trailing separator. The server verifies via `SHA256withECDSA` over
|
||||
* these UTF-8 bytes.
|
||||
*/
|
||||
private fun canonicalize(
|
||||
devicePublicKey: String,
|
||||
|
|
@ -57,15 +59,15 @@ internal class SignedRequestPayload @Inject constructor(
|
|||
attestationToken: String?,
|
||||
metadata: DeviceMetadata,
|
||||
): ByteArray = buildString {
|
||||
append(devicePublicKey).append('\n')
|
||||
append(nonce).append('\n')
|
||||
append(attestationToken.orEmpty()).append('\n')
|
||||
append(metadata.deviceModel).append('\n')
|
||||
append(metadata.os).append('\n')
|
||||
append(metadata.osVersion).append('\n')
|
||||
append(metadata.appVersion).append('\n')
|
||||
append(metadata.userAgent).append('\n')
|
||||
append(metadata.locale).append('\n')
|
||||
append(devicePublicKey).append(':')
|
||||
append(nonce).append(':')
|
||||
append(attestationToken.orEmpty()).append(':')
|
||||
append(metadata.deviceModel).append(':')
|
||||
append(metadata.os).append(':')
|
||||
append(metadata.osVersion).append(':')
|
||||
append(metadata.appVersion).append(':')
|
||||
append(metadata.userAgent).append(':')
|
||||
append(metadata.locale).append(':')
|
||||
append(metadata.timezone)
|
||||
}.toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,326 @@
|
|||
package com.tangem.lib.auth.session.internal
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.mutablePreferencesOf
|
||||
import arrow.core.None
|
||||
import arrow.core.Some
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.tangem.datasource.api.auth.AuthApi
|
||||
import com.tangem.datasource.api.auth.models.request.WalletRegistrationRequest
|
||||
import com.tangem.datasource.api.auth.models.response.NonceApiResponse
|
||||
import com.tangem.datasource.api.auth.models.response.TokenApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponse
|
||||
import com.tangem.datasource.api.common.response.ApiResponseError
|
||||
import com.tangem.datasource.local.preferences.AppPreferencesStore
|
||||
import com.tangem.datasource.local.preferences.PreferencesKeys
|
||||
import com.tangem.lib.auth.devicekey.DeviceKeyManager
|
||||
import com.tangem.lib.auth.nonce.AuthNonceDecryptor
|
||||
import com.tangem.lib.auth.session.WalletRegistrationError
|
||||
import com.tangem.lib.auth.session.WalletSignatureBundle
|
||||
import com.tangem.lib.auth.session.WalletSigner
|
||||
import com.tangem.lib.auth.session.SessionTokensStore
|
||||
import com.tangem.utils.coroutines.TestingCoroutineDispatcherProvider
|
||||
import com.tangem.utils.info.AppInfoProvider
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class DefaultWalletRegistrarTest {
|
||||
|
||||
private val authApi: AuthApi = mockk()
|
||||
private val store: SessionTokensStore = mockk(relaxUnitFun = true)
|
||||
private val deviceKeyManager: DeviceKeyManager = mockk()
|
||||
private val nonceDecryptor: AuthNonceDecryptor = mockk()
|
||||
private val appInfoProvider: AppInfoProvider = mockk(relaxed = true)
|
||||
private val signedRequestPayload = SignedRequestPayload(appInfoProvider)
|
||||
private val errorConverter = AuthErrorConverter()
|
||||
private val dispatchers = TestingCoroutineDispatcherProvider()
|
||||
|
||||
private val preferencesDataStore = InMemoryPreferencesDataStore()
|
||||
private val appPreferencesStore = AppPreferencesStore(
|
||||
moshi = Moshi.Builder().build(),
|
||||
dispatchers = dispatchers,
|
||||
preferencesDataStore = preferencesDataStore,
|
||||
)
|
||||
|
||||
private lateinit var registrar: DefaultWalletRegistrar
|
||||
|
||||
private val mobileSigner = WalletSigner {
|
||||
WalletSignatureBundle(
|
||||
walletSignature = ByteArray(65) { 1 },
|
||||
walletSignatureSalt = ByteArray(16) { 2 },
|
||||
cardSignature = null,
|
||||
cardSignatureSalt = null,
|
||||
walletStatusByte = null,
|
||||
)
|
||||
}
|
||||
|
||||
private val coldSigner = WalletSigner {
|
||||
WalletSignatureBundle(
|
||||
walletSignature = ByteArray(65) { 1 },
|
||||
walletSignatureSalt = ByteArray(16) { 2 },
|
||||
cardSignature = ByteArray(65) { 3 },
|
||||
cardSignatureSalt = ByteArray(16) { 4 },
|
||||
walletStatusByte = 0x82.toByte(),
|
||||
)
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
clearMocks(authApi, store, deviceKeyManager, nonceDecryptor)
|
||||
preferencesDataStore.reset()
|
||||
mockkStatic(android.util.Base64::class)
|
||||
every { android.util.Base64.encodeToString(any(), any()) } answers {
|
||||
java.util.Base64.getEncoder().encodeToString(firstArg())
|
||||
}
|
||||
registrar = DefaultWalletRegistrar(
|
||||
authApi = authApi,
|
||||
store = store,
|
||||
deviceKeyManager = deviceKeyManager,
|
||||
nonceDecryptor = nonceDecryptor,
|
||||
signedRequestPayload = signedRequestPayload,
|
||||
errorConverter = errorConverter,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
dispatchers = dispatchers,
|
||||
)
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
fun teardown() = unmockkAll()
|
||||
|
||||
@Test
|
||||
fun `register MOBILE posts wallet and persists tokens and marker with null card fields`() = runTest {
|
||||
stubHappyPath()
|
||||
val slot = slot<WalletRegistrationRequest>()
|
||||
coEvery { authApi.registerWallet(capture(slot)) } returns tokenSuccess()
|
||||
|
||||
val result = registrar.register(WALLET_ID, mobileSigner)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
coVerify { authApi.requestWalletNonce(any()) }
|
||||
coVerify { store.save(any()) }
|
||||
assertThat(registeredIds()).contains(WALLET_ID)
|
||||
val request = slot.captured
|
||||
assertThat(request.walletId).isEqualTo(WALLET_ID)
|
||||
assertThat(request.walletSignature).isEqualTo(base64(ByteArray(65) { 1 }))
|
||||
assertThat(request.walletSignatureSalt).isEqualTo(base64(ByteArray(16) { 2 }))
|
||||
assertThat(request.cardSignature).isNull()
|
||||
assertThat(request.cardSignatureSalt).isNull()
|
||||
assertThat(request.walletStatus).isNull()
|
||||
assertThat(request.attestationToken).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `register COLD posts wallet with card fields and single-byte walletStatus`() = runTest {
|
||||
stubHappyPath()
|
||||
val slot = slot<WalletRegistrationRequest>()
|
||||
coEvery { authApi.registerWallet(capture(slot)) } returns tokenSuccess()
|
||||
|
||||
val result = registrar.register(WALLET_ID, coldSigner)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
val request = slot.captured
|
||||
assertThat(request.cardSignature).isEqualTo(base64(ByteArray(65) { 3 }))
|
||||
assertThat(request.cardSignatureSalt).isEqualTo(base64(ByteArray(16) { 4 }))
|
||||
// walletStatus must be exactly one Base64-encoded byte (0x82).
|
||||
assertThat(request.walletStatus).isEqualTo(base64(byteArrayOf(0x82.toByte())))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `registering two different wallets accumulates both ids in the marker set`() = runTest {
|
||||
stubHappyPath()
|
||||
coEvery { authApi.registerWallet(any()) } returns tokenSuccess()
|
||||
|
||||
assertThat(registrar.register(WALLET_ID, mobileSigner).isRight()).isTrue()
|
||||
assertThat(registrar.register(OTHER_WALLET_ID, mobileSigner).isRight()).isTrue()
|
||||
|
||||
// markRegistered must read-modify-write atomically, not overwrite the existing set.
|
||||
assertThat(registeredIds()).containsExactly(WALLET_ID, OTHER_WALLET_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `register short-circuits without network when walletId already registered`() = runTest {
|
||||
preferencesDataStore.edit { it[PreferencesKeys.REGISTERED_WALLET_IDS_KEY] = setOf(WALLET_ID) }
|
||||
|
||||
val result = registrar.register(WALLET_ID, mobileSigner)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
coVerify(exactly = 0) { authApi.requestWalletNonce(any()) }
|
||||
coVerify(exactly = 0) { authApi.registerWallet(any()) }
|
||||
coVerify(exactly = 0) { store.save(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `register returns DeviceKeyUnavailable when keystore has no key`() = runTest {
|
||||
coEvery { deviceKeyManager.getPublicKey() } returns None
|
||||
|
||||
val result = registrar.register(WALLET_ID, mobileSigner)
|
||||
|
||||
assertThat(result.leftOrNull()).isEqualTo(WalletRegistrationError.DeviceKeyUnavailable)
|
||||
coVerify(exactly = 0) { authApi.requestWalletNonce(any()) }
|
||||
assertThat(registeredIds()).doesNotContain(WALLET_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `register surfaces nonce-endpoint API error`() = runTest {
|
||||
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65))
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
coEvery { authApi.requestWalletNonce(any()) } returns ApiResponse.Error(
|
||||
cause = ApiResponseError.HttpException(
|
||||
code = ApiResponseError.HttpException.Code.TOO_MANY_REQUESTS,
|
||||
message = "rate-limited",
|
||||
errorBody = null,
|
||||
),
|
||||
) as ApiResponse<NonceApiResponse>
|
||||
|
||||
val result = registrar.register(WALLET_ID, mobileSigner)
|
||||
|
||||
assertThat(result.leftOrNull()).isInstanceOf(WalletRegistrationError.Api::class.java)
|
||||
coVerify(exactly = 0) { authApi.registerWallet(any()) }
|
||||
assertThat(registeredIds()).doesNotContain(WALLET_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `register returns NonceDecryptionFailed when decryptor throws`() = runTest {
|
||||
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65))
|
||||
coEvery { authApi.requestWalletNonce(any()) } returns nonceSuccess()
|
||||
coEvery { nonceDecryptor.decryptNonce("abc") } throws IllegalStateException("OAEP failed")
|
||||
|
||||
val result = registrar.register(WALLET_ID, mobileSigner)
|
||||
|
||||
assertThat(result.leftOrNull()).isInstanceOf(WalletRegistrationError.NonceDecryptionFailed::class.java)
|
||||
coVerify(exactly = 0) { authApi.registerWallet(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `register returns SigningFailed when signer throws (e g cancelled NFC or biometric)`() = runTest {
|
||||
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65))
|
||||
coEvery { authApi.requestWalletNonce(any()) } returns nonceSuccess()
|
||||
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
|
||||
val failingSigner = WalletSigner { throw IllegalStateException("user cancelled") }
|
||||
|
||||
val result = registrar.register(WALLET_ID, failingSigner)
|
||||
|
||||
assertThat(result.leftOrNull()).isInstanceOf(WalletRegistrationError.SigningFailed::class.java)
|
||||
coVerify(exactly = 0) { authApi.registerWallet(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `register surfaces wallet-endpoint API error and does not touch tokens or marker`() = runTest {
|
||||
stubHappyPath()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
coEvery { authApi.registerWallet(any()) } returns ApiResponse.Error(
|
||||
cause = ApiResponseError.HttpException(
|
||||
code = ApiResponseError.HttpException.Code.FORBIDDEN,
|
||||
message = "max wallets",
|
||||
errorBody = null,
|
||||
),
|
||||
) as ApiResponse<TokenApiResponse>
|
||||
|
||||
val result = registrar.register(WALLET_ID, mobileSigner)
|
||||
|
||||
assertThat(result.leftOrNull()).isInstanceOf(WalletRegistrationError.Api::class.java)
|
||||
coVerify(exactly = 0) { store.save(any()) }
|
||||
assertThat(registeredIds()).doesNotContain(WALLET_ID)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `register treats 409 Conflict as success, marks registered without persisting tokens`() = runTest {
|
||||
stubHappyPath()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
coEvery { authApi.registerWallet(any()) } returns ApiResponse.Error(
|
||||
cause = ApiResponseError.HttpException(
|
||||
code = ApiResponseError.HttpException.Code.CONFLICT,
|
||||
message = "wallet already registered",
|
||||
errorBody = null,
|
||||
),
|
||||
) as ApiResponse<TokenApiResponse>
|
||||
|
||||
val result = registrar.register(WALLET_ID, mobileSigner)
|
||||
|
||||
assertThat(result.isRight()).isTrue()
|
||||
assertThat(registeredIds()).contains(WALLET_ID)
|
||||
coVerify(exactly = 0) { store.save(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `register returns PersistenceFailed when SessionTokensStore_save throws`() = runTest {
|
||||
stubHappyPath()
|
||||
coEvery { authApi.registerWallet(any()) } returns tokenSuccess()
|
||||
coEvery { store.save(any()) } throws IllegalStateException("DataStore I/O")
|
||||
|
||||
val result = registrar.register(WALLET_ID, mobileSigner)
|
||||
|
||||
assertThat(result.leftOrNull()).isInstanceOf(WalletRegistrationError.PersistenceFailed::class.java)
|
||||
// Marker must stay unset so the next attempt retries cleanly.
|
||||
assertThat(registeredIds()).doesNotContain(WALLET_ID)
|
||||
}
|
||||
|
||||
private fun stubHappyPath() {
|
||||
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65))
|
||||
coEvery { authApi.requestWalletNonce(any()) } returns nonceSuccess()
|
||||
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
|
||||
}
|
||||
|
||||
private fun nonceSuccess() = ApiResponse.Success(
|
||||
data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"),
|
||||
)
|
||||
|
||||
private fun tokenSuccess() = ApiResponse.Success(
|
||||
data = TokenApiResponse(
|
||||
accessToken = "fresh-access",
|
||||
accessTokenExpiresAt = "2024-01-01T00:00:00Z",
|
||||
refreshToken = "fresh-rt",
|
||||
refreshTokenExpiresAt = "2024-02-01T00:00:00Z",
|
||||
walletIds = listOf(WALLET_ID),
|
||||
),
|
||||
)
|
||||
|
||||
private fun registeredIds(): Set<String> =
|
||||
preferencesDataStore.current()[PreferencesKeys.REGISTERED_WALLET_IDS_KEY].orEmpty()
|
||||
|
||||
private fun base64(bytes: ByteArray): String = java.util.Base64.getEncoder().encodeToString(bytes)
|
||||
|
||||
private companion object {
|
||||
const val WALLET_ID = "wallet-1"
|
||||
const val OTHER_WALLET_ID = "wallet-2"
|
||||
}
|
||||
|
||||
/** Minimal in-memory [DataStore] implementation — only the surface area used by tests. */
|
||||
private class InMemoryPreferencesDataStore : DataStore<Preferences> {
|
||||
|
||||
private var preferences: MutablePreferences = mutablePreferencesOf()
|
||||
|
||||
override val data get() = flowOf(preferences)
|
||||
|
||||
override suspend fun updateData(transform: suspend (t: Preferences) -> Preferences): Preferences {
|
||||
preferences = transform(preferences).toMutablePreferences()
|
||||
return preferences
|
||||
}
|
||||
|
||||
fun edit(block: (MutablePreferences) -> Unit) {
|
||||
preferences = preferences.toMutablePreferences().also(block)
|
||||
}
|
||||
|
||||
fun current(): Preferences = preferences
|
||||
|
||||
fun reset() {
|
||||
preferences = mutablePreferencesOf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -43,7 +43,7 @@ class SignedRequestPayloadTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun `canonicalize produces newline-separated representation in the documented field order`() {
|
||||
fun `canonicalize produces colon-separated representation in the documented field order`() {
|
||||
val metadata = DeviceMetadata(
|
||||
deviceModel = "Pixel 8",
|
||||
os = "Android",
|
||||
|
|
@ -62,19 +62,9 @@ class SignedRequestPayloadTest {
|
|||
|
||||
val bytes = signedRequestPayload.canonicalize(payload)
|
||||
|
||||
// Must match the backend's `:`-joined canonicalisation byte-for-byte.
|
||||
assertThat(bytes.toString(Charsets.UTF_8)).isEqualTo(
|
||||
"""
|
||||
pub
|
||||
nonce-1
|
||||
attestation
|
||||
Pixel 8
|
||||
Android
|
||||
14
|
||||
5.40.0
|
||||
Tangem/5.40.0 (Pixel 8; Android 14)
|
||||
en-US
|
||||
Europe/Moscow
|
||||
""".trimIndent(),
|
||||
"pub:nonce-1:attestation:Pixel 8:Android:14:5.40.0:Tangem/5.40.0 (Pixel 8; Android 14):en-US:Europe/Moscow",
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -98,9 +88,9 @@ class SignedRequestPayloadTest {
|
|||
|
||||
val bytes = signedRequestPayload.canonicalize(payload)
|
||||
|
||||
// The null attestationToken collapses to an empty slot between `nonce` and `deviceModel`.
|
||||
// The null attestationToken collapses to an empty segment between `nonce` and `deviceModel`.
|
||||
assertThat(bytes.toString(Charsets.UTF_8)).isEqualTo(
|
||||
"pub\nnonce-1\n\nPixel 8\nAndroid\n14\n5.40.0\nTangem/5.40.0 (Pixel 8; Android 14)\nen-US\nEurope/Moscow",
|
||||
"pub:nonce-1::Pixel 8:Android:14:5.40.0:Tangem/5.40.0 (Pixel 8; Android 14):en-US:Europe/Moscow",
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue