Updated on 2026-08-14

This commit is contained in:
Tangem 2026-06-01 20:05:51 +03:00
commit d0b35bc331
680 changed files with 26556 additions and 2709 deletions

View file

@ -95,6 +95,7 @@ dependencies {
implementation(deps.kotlin.coroutines)
implementation(deps.kotlin.coroutines.rx2)
implementation(deps.kotlin.datetime)
implementation(deps.kotlin.serialization)
/** Logging */

View file

@ -0,0 +1,45 @@
package com.tangem.datasource.api.auth
import com.tangem.datasource.api.auth.models.request.AuthApiRequest
import com.tangem.datasource.api.auth.models.request.NonceApiRequest
import com.tangem.datasource.api.auth.models.request.RefreshApiRequest
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 retrofit2.http.Body
import retrofit2.http.POST
/**
* Tangem Auth Service API (JWT session tokens / DPoP interceptor / refresh rotation)
*/
interface AuthApi {
/**
* Request authentication nonce.
*
* Generates a nonce bound to the device public key for the authentication flow.
*/
@POST("api/v1/auth/nonce/auth")
suspend fun requestAuthNonce(@Body request: NonceApiRequest): ApiResponse<NonceApiResponse>
/**
* Authenticate device.
*
* Authenticates a previously registered device using a device-key signature. Issues a new
* JWT access token with bound `walletIds[]` and risk tier. All subsequent auth after
* registration uses this endpoint.
*/
@POST("api/v1/auth/authenticate")
suspend fun authenticate(@Body request: AuthApiRequest): ApiResponse<TokenApiResponse>
/**
* Refresh tokens.
*
* Rotates the refresh token and issues a new access token. Uses refresh-token rotation
* with family-based reuse detection replaying a consumed token revokes the entire token
* family (SR-8). Sender-constraint is verified via the DPoP-proof header (`cnf.jkt`).
*/
@POST("api/v1/auth/refresh")
@RequiresSessionAuth
suspend fun refresh(@Body request: RefreshApiRequest): ApiResponse<TokenApiResponse>
}

View file

@ -0,0 +1,16 @@
package com.tangem.datasource.api.auth
/**
* Marks a Retrofit endpoint as requiring an authenticated session (DPoP, see
* [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)).
*
* Read at runtime by the session-auth interceptor: only methods
* carrying this annotation receive `Authorization: DPoP <access-token>` + `DPoP: <proof-jwt>`
* headers; unannotated methods (e.g. public nonce endpoints) pass through unchanged.
*
* Mirrors the per-operation `security` blocks in the backend OpenAPI contract; follows the
* same on-method annotation pattern as `@ReadTimeout` / `@ConnectTimeout`.
*/
@Target(AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
annotation class RequiresSessionAuth

View file

@ -0,0 +1,46 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Authentication request — authenticates a previously registered device. */
@JsonClass(generateAdapter = true)
data class AuthApiRequest(
/** Signed authentication payload. */
@Json(name = "payload") val payload: AuthenticationPayload,
/** EC signature over the authentication payload, signed by the device private key (Base64). */
@Json(name = "signature") val signature: String,
)
/** Signed authentication payload — the data that is signed by the device private key. */
@JsonClass(generateAdapter = true)
data class AuthenticationPayload(
/** Base64-encoded EC public key of the device (e.g. `MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...`). */
@Json(name = "devicePublicKey") val devicePublicKey: String,
/** Deciphered nonce value from the nonce endpoint. */
@Json(name = "nonce") val nonce: String,
/** Platform attestation token (Play Integrity / App Attest). */
@Json(name = "attestationToken") val attestationToken: String?,
/** Client-reported device metadata. */
@Json(name = "metadata") val metadata: DeviceMetadata,
) {
/** Device metadata collection. */
@JsonClass(generateAdapter = true)
data class DeviceMetadata(
/** Device hardware model (e.g. `iPhone 15 Pro`). */
@Json(name = "deviceModel") val deviceModel: String?,
/** Operating system (`android` / `ios`). */
@Json(name = "os") val os: String,
/** OS version string (e.g. `17.4.1`). */
@Json(name = "osVersion") val osVersion: String?,
/** Application version (e.g. `5.8.0`). */
@Json(name = "appVersion") val appVersion: String?,
/** User-Agent header (e.g. `Tangem/5.8.0 (iPhone; iOS 17.4.1; Scale/3.00)`). */
@Json(name = "userAgent") val userAgent: String?,
/** Client locale (e.g. `en-US`). */
@Json(name = "locale") val locale: String?,
/** Client timezone (e.g. `Europe/Moscow`). */
@Json(name = "timezone") val timezone: String?,
)
}

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Request body for nonce generation (auth, upgrade, wallet flows). */
@JsonClass(generateAdapter = true)
data class NonceApiRequest(
/** Base64-encoded EC public key of the device (e.g. `MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...`). */
@Json(name = "devicePublicKey") val devicePublicKey: String,
)

View file

@ -0,0 +1,11 @@
package com.tangem.datasource.api.auth.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Token refresh request. */
@JsonClass(generateAdapter = true)
data class RefreshApiRequest(
/** Refresh token from a previous token response. */
@Json(name = "refreshToken") val refreshToken: String,
)

View file

@ -0,0 +1,13 @@
package com.tangem.datasource.api.auth.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Ciphered nonce response. */
@JsonClass(generateAdapter = true)
data class NonceApiResponse(
/** RSA-OAEP ciphered nonce value (Base64). */
@Json(name = "cipheredNonce") val cipheredNonce: String,
/** Nonce expiration timestamp (ISO-8601). */
@Json(name = "expiresAt") val expiresAt: String,
)

View file

@ -0,0 +1,26 @@
package com.tangem.datasource.api.auth.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/**
* RFC 9457 / RFC 7807 Problem Details response. Returned by Tangem Auth Service with
* `Content-Type: application/problem+json` on every 4xx / 5xx response.
*/
@JsonClass(generateAdapter = true)
data class ProblemDetailResponse(
/** URI identifying the problem type. */
@Json(name = "type") val type: String,
/** Short human-readable summary (e.g. `"Too Many Requests"`). */
@Json(name = "title") val title: String,
/** HTTP status code. */
@Json(name = "status") val status: Int,
/** Human-readable explanation. */
@Json(name = "detail") val detail: String?,
/** URI reference to this occurrence (e.g. `"/api/v1/auth/refresh"`). */
@Json(name = "instance") val instance: String?,
/** Application-specific error code. */
@Json(name = "code") val code: String?,
/** Retry delay for rate limiting (`429`). */
@Json(name = "retryAfterSeconds") val retryAfterSeconds: Int?,
)

View file

@ -0,0 +1,19 @@
package com.tangem.datasource.api.auth.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
/** Token response — contains JWT access token and optional refresh token. */
@JsonClass(generateAdapter = true)
data class TokenApiResponse(
/** JWT access token (HMAC-SHA256 signed). */
@Json(name = "accessToken") val accessToken: String,
/** Access token expiration timestamp (ISO-8601). */
@Json(name = "accessTokenExpiresAt") val accessTokenExpiresAt: String,
/** Refresh token for token rotation. `null` for ORANGE tier (requires device challenge each time). */
@Json(name = "refreshToken") val refreshToken: String?,
/** Refresh token expiration timestamp (ISO-8601). `null` iff [refreshToken] is `null`. */
@Json(name = "refreshTokenExpiresAt") val refreshTokenExpiresAt: String?,
/** List of wallet IDs bound to this device. */
@Json(name = "walletIds") val walletIds: List<String>,
)

View file

@ -33,6 +33,7 @@ sealed class ApiConfig {
News,
GaslessTxService,
SurveySparrow,
Auth,
}
private fun initializeId(): ID {
@ -49,6 +50,7 @@ sealed class ApiConfig {
is News -> ID.News
is GaslessTxService -> ID.GaslessTxService
is SurveySparrow -> ID.SurveySparrow
is Auth -> ID.Auth
}
}

View file

@ -0,0 +1,59 @@
package com.tangem.datasource.api.common.config
import com.tangem.datasource.BuildConfig
/**
* Tangem Auth Service [ApiConfig] endpoints for device registration, authentication,
* nonce issuance, refresh token rotation, and JWKS publication.
*/
internal class Auth : ApiConfig() {
override val defaultEnvironment: ApiEnvironment = getInitialEnvironment()
override val environmentConfigs: List<ApiEnvironmentConfig> = listOf(
createDevEnvironment(),
createMockedEnvironment(),
createProdEnvironment(),
)
private fun getInitialEnvironment(): ApiEnvironment {
return when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
-> ApiEnvironment.DEV
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
}
private fun createDevEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.DEV,
baseUrl = DEV_BASE_URL,
headers = emptyMap(),
)
private fun createMockedEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.MOCK,
baseUrl = MOCK_BASE_URL,
headers = emptyMap(),
)
private fun createProdEnvironment(): ApiEnvironmentConfig = ApiEnvironmentConfig(
environment = ApiEnvironment.PROD,
baseUrl = PROD_BASE_URL,
headers = emptyMap(),
)
private companion object {
// TODO Replace with real Auth Service hosts once the backend team confirms deployment.
// Swagger currently only declares `http://localhost:8080` for local development.
// [REDACTED_JIRA]
private const val DEV_BASE_URL = "http://localhost:8080/"
private const val MOCK_BASE_URL = "http://localhost:8080/"
private const val PROD_BASE_URL = "http://localhost:8080/"
}
}

View file

@ -97,6 +97,12 @@ interface TangemPayApi {
@Body body: ReissueCardRequest,
): ApiResponse<ReissueCardResponse>
@POST("v1/customer/card/close")
suspend fun closeCard(
@Header("Authorization") authHeader: String,
@Body body: CloseCardRequest,
): ApiResponse<CloseCardResponse>
@POST("v1/customer/card/withdraw/data")
suspend fun getWithdrawData(
@Header("Authorization") authHeader: String,

View file

@ -0,0 +1,9 @@
package com.tangem.datasource.api.pay.models.request
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CloseCardRequest(
@Json(name = "card_id") val cardId: String,
)

View file

@ -0,0 +1,15 @@
package com.tangem.datasource.api.pay.models.response
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class CloseCardResponse(
@Json(name = "result") val result: Result,
) {
@JsonClass(generateAdapter = true)
data class Result(
@Json(name = "order_id") val orderId: String,
@Json(name = "status") val status: OrderResponse.Result.Status,
)
}

View file

@ -113,4 +113,10 @@ internal object ApiConfigsModule {
fun provideSurveySparrowConfig(environmentConfig: EnvironmentConfig): ApiConfig {
return SurveySparrow(environmentConfig)
}
@Provides
@IntoSet
fun provideAuthConfig(): ApiConfig {
return Auth()
}
}

View file

@ -1,6 +1,7 @@
package com.tangem.datasource.di
import com.tangem.datasource.BuildConfig
import com.tangem.datasource.api.auth.AuthApi
import com.tangem.datasource.api.common.blockaid.BlockAidApi
import com.tangem.datasource.api.surveysparrow.SurveySparrowApi
import com.tangem.datasource.api.common.config.ApiConfig
@ -208,6 +209,15 @@ internal object NetworkModule {
)
}
@Provides
@Singleton
fun provideAuthApi(retrofitApiBuilder: RetrofitApiBuilder): AuthApi {
return retrofitApiBuilder.build(
apiConfigId = ApiConfig.ID.Auth,
applyTimeoutAnnotations = false,
)
}
@Provides
@Singleton
fun provideGaslessTxServiceApi(retrofitApiBuilder: RetrofitApiBuilder): GaslessTxServiceApi {

View file

@ -3,8 +3,10 @@ package com.tangem.datasource.di
import com.tangem.datasource.local.datastore.RuntimeDataStore
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.visa.DefaultTangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.DefaultTangemPayCloseCardStore
import com.tangem.datasource.local.visa.DefaultTangemPayReissueCardStore
import com.tangem.datasource.local.visa.TangemPayCardFrozenStateStore
import com.tangem.datasource.local.visa.TangemPayCloseCardStore
import com.tangem.datasource.local.visa.TangemPayReissueCardStore
import dagger.Module
import dagger.Provides
@ -32,4 +34,12 @@ internal object TangemPayStoresModule {
prefs = prefs,
)
}
@Provides
@Singleton
fun provideTangemPayCloseCardStore(prefs: AppPreferencesStore): TangemPayCloseCardStore {
return DefaultTangemPayCloseCardStore(
prefs = prefs,
)
}
}

View file

@ -14,6 +14,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Named
import javax.inject.Singleton
@Module
@ -26,6 +27,11 @@ internal object ConfigModule {
return GeneratedEnvironmentConfigConverter.convert()
}
@Provides
@Singleton
@Named("authServiceKey")
fun provideAuthServiceKey(environmentConfig: EnvironmentConfig): String? = environmentConfig.authServiceKey
@Provides
@Singleton
fun provideTestnetTokensStorage(assetLoader: AssetLoader): TestnetTokensStorage {

View file

@ -16,11 +16,15 @@ import com.tangem.datasource.api.utils.ConnectTimeout
import com.tangem.datasource.api.utils.ReadTimeout
import com.tangem.datasource.api.utils.WriteTimeout
import com.tangem.datasource.di.NetworkMoshi
import com.tangem.datasource.local.config.environment.EnvironmentConfig
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.logs.SensitiveUrlMasker
import com.tangem.datasource.utils.NetworkLogsSaveInterceptor
import com.tangem.datasource.utils.WireMockRedirectInterceptor
import com.tangem.datasource.utils.addHeaders
import com.tangem.utils.JsonStringValuesExtractor
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import retrofit2.Invocation
@ -41,6 +45,7 @@ import javax.inject.Singleton
*
[REDACTED_AUTHOR]
*/
@Suppress("LongParameterList")
@Singleton
internal class RetrofitApiBuilder @Inject constructor(
private val apiConfigs: ApiConfigs,
@ -49,10 +54,20 @@ internal class RetrofitApiBuilder @Inject constructor(
private val analyticsErrorHandler: AnalyticsErrorHandler,
@ApplicationContext private val context: Context,
private val appLogsStore: AppLogsStore,
private val environmentConfig: EnvironmentConfig,
) {
private val configsBaseUrls: Map<ApiConfig.ID, Set<String>> = getConfigsBaseUrls()
private val sensitiveUrlMasker: SensitiveUrlMasker by lazy {
val json = Json.encodeToJsonElement(EnvironmentConfig.serializer(), environmentConfig)
// Drop URL-shaped values (e.g. public endpoint URLs from config); they are not secrets
// and would obscure unrelated requests in logs.
val values = JsonStringValuesExtractor.extract(json)
.filter { it.isNotBlank() && !it.startsWith("http", ignoreCase = true) }
SensitiveUrlMasker(values)
}
/**
* Builds a Retrofit API instance for the specified API configuration ID
*
@ -179,7 +194,7 @@ internal class RetrofitApiBuilder @Inject constructor(
private fun OkHttpClient.Builder.applyLogsSaving(): OkHttpClient.Builder {
return addInterceptor(
interceptor = NetworkLogsSaveInterceptor(appLogsStore),
interceptor = NetworkLogsSaveInterceptor(appLogsStore, sensitiveUrlMasker),
)
}

View file

@ -4,7 +4,10 @@ import com.tangem.blockchain.common.BlockchainSdkConfig
import com.tangem.datasource.local.config.environment.models.ExpressModel
import com.tangem.datasource.local.config.environment.models.P2PKeys
import com.tangem.datasource.local.config.environment.models.SurveySparrowSwapRatingConfig
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@Serializable
data class EnvironmentConfig(
val moonPayApiKey: String = "",
val moonPayApiSecretKey: String = "",
@ -32,5 +35,7 @@ data class EnvironmentConfig(
val gaslessTxApiKey: String? = null,
val customerIoCdpApiKey: String? = null,
val surveySparrowToken: String? = null,
@Transient
val surveySparrowSwapRating: SurveySparrowSwapRatingConfig? = null,
val authServiceKey: String? = null,
)

View file

@ -56,6 +56,7 @@ internal object GeneratedEnvironmentConfigConverter {
customerIoCdpApiKey = GeneratedEnvironmentConfig.CustomerIO.androidApiKey,
surveySparrowToken = GeneratedEnvironmentConfig.SurveySparrow.apiKey,
surveySparrowSwapRating = createSurveySparrowSwapRating(),
authServiceKey = null, // TODO: provide service key [REDACTED_JIRA]
)
}

View file

@ -1,7 +1,11 @@
package com.tangem.datasource.local.config.environment.models
import kotlinx.serialization.Serializable
@Serializable
data class ExpressModel(val apiKey: String, val signVerifierPublicKey: String)
@Serializable
data class P2PKeys(val mainnet: String, val hoodi: String)
data class SurveySparrowSwapRatingConfig(

View file

@ -0,0 +1,24 @@
package com.tangem.datasource.local.logs
class SensitiveUrlMasker(sensitiveValues: Collection<String>) {
// Sorted by descending length so a value that is a prefix of another (e.g. "my-node" vs
// "my-node-prod") cannot mask the shorter one first and leave the suffix in the log.
private val sensitiveValues: List<String> = sensitiveValues
.distinct()
.sortedByDescending(String::length)
fun mask(url: String): String {
var result = url
for (value in sensitiveValues) {
if (result.contains(value, ignoreCase = true)) {
result = result.replace(value, MASKED_VALUE, ignoreCase = true)
}
}
return result
}
companion object {
const val MASKED_VALUE = "******"
}
}

View file

@ -1,7 +1,7 @@
package com.tangem.datasource.local.visa
import com.tangem.datasource.local.datastore.core.StringKeyDataStore
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import kotlinx.coroutines.flow.Flow
internal class DefaultTangemPayCardFrozenStateStore(

View file

@ -0,0 +1,29 @@
package com.tangem.datasource.local.visa
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.tangem.datasource.local.preferences.AppPreferencesStore
import com.tangem.datasource.local.preferences.utils.getSyncOrNull
import com.tangem.datasource.local.preferences.utils.store
internal class DefaultTangemPayCloseCardStore(
private val prefs: AppPreferencesStore,
) : TangemPayCloseCardStore {
override suspend fun setCloseOrderId(cardId: String, orderId: String?) {
if (orderId == null) {
prefs.edit { it.remove(getCloseKey(cardId)) }
} else {
prefs.store(
key = getCloseKey(cardId),
value = orderId,
)
}
}
override suspend fun getOrderId(cardId: String): String? {
return prefs.getSyncOrNull(key = getCloseKey(cardId))
}
private fun getCloseKey(cardId: String) = stringPreferencesKey("tangem_pay_close_card_$cardId")
}

View file

@ -1,6 +1,6 @@
package com.tangem.datasource.local.visa
import com.tangem.domain.visa.model.TangemPayCardFrozenState
import com.tangem.domain.models.pay.TangemPayCardFrozenState
import kotlinx.coroutines.flow.Flow
interface TangemPayCardFrozenStateStore {

View file

@ -0,0 +1,8 @@
package com.tangem.datasource.local.visa
interface TangemPayCloseCardStore {
suspend fun setCloseOrderId(cardId: String, orderId: String?)
suspend fun getOrderId(cardId: String): String?
}

View file

@ -86,8 +86,8 @@ sealed interface PaymentAccountStatusValueDM {
@Json(name = "display_name") val displayName: String?,
@Json(name = "actual_daily_limit") val actualDailyLimit: SerializedBigDecimal?,
@Json(name = "admin_daily_limit") val adminDailyLimit: SerializedBigDecimal?,
@Json(name = "is_frozen") val isFrozen: Boolean,
@Json(name = "frozen_state") val frozenState: String,
@Json(name = "last_digits") val lastDigits: String,
@Json(name = "is_reissuing") val isReissuing: Boolean,
@Json(name = "state") val state: String,
)
}

View file

@ -1,7 +1,9 @@
package com.tangem.datasource.utils
import com.tangem.datasource.local.logs.AppLogsStore
import com.tangem.datasource.local.logs.SensitiveUrlMasker
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
@ -22,11 +24,15 @@ private const val JSON_INDENT_SPACES = 4
* Interceptor for save network requests and responses logs
*
* @property appLogsStore app logs store
* @property sensitiveUrlMasker masker for sensitive data in URLs
* @property shouldCheckResponseBodySize whether to skip logging large response bodies
*
[REDACTED_AUTHOR]
*/
class NetworkLogsSaveInterceptor(
private val appLogsStore: AppLogsStore,
private val sensitiveUrlMasker: SensitiveUrlMasker? = null,
private val shouldCheckResponseBodySize: Boolean = false,
) : Interceptor {
@Throws(IOException::class)
@ -65,7 +71,7 @@ class NetworkLogsSaveInterceptor(
val connection = chain.connection()
val connectionProtocol = if (connection != null) " ${connection.protocol()}" else ""
saveLogMessage("--> ${request.method} ${request.url}$connectionProtocol\n")
saveLogMessage("--> ${request.method} ${request.url.maskSensitiveInfo()}$connectionProtocol\n")
}
private fun logRequestMessage(chain: Interceptor.Chain, request: Request) {
@ -73,7 +79,7 @@ class NetworkLogsSaveInterceptor(
val connectionProtocol = if (connection != null) " ${connection.protocol()}" else ""
saveLogMessage(
"--> ${request.method} ${request.url}$connectionProtocol\n",
"--> ${request.method} ${request.url.maskSensitiveInfo()}$connectionProtocol\n",
createRequestEndMessage(request),
)
}
@ -110,7 +116,7 @@ class NetworkLogsSaveInterceptor(
val tookMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs)
saveLogMessage(
"<-- ${response.code}",
" ${response.request.url} (${tookMs}ms)\n",
" ${response.request.url.maskSensitiveInfo()} (${tookMs}ms)\n",
)
}
@ -123,39 +129,45 @@ class NetworkLogsSaveInterceptor(
"<-- END HTTP"
} else if (bodyHasUnknownEncoding(response.headers)) {
"<-- END HTTP (encoded body omitted)"
} else if (shouldCheckResponseBodySize && contentLength > WRITE_LOG_THRESHOLD_BYTES_SIZE) {
"Response size too large: $contentLength bytes \n<-- END HTTP"
} else {
val source = responseBody.source()
source.request(Long.MAX_VALUE)
var buffer = source.buffer
var gzippedLength: Long? = null
if ("gzip".equals(responseHeaders["Content-Encoding"], ignoreCase = true)) {
gzippedLength = buffer.size
GzipSource(buffer.clone()).use { gzippedResponseBody ->
buffer = Buffer()
buffer.writeAll(gzippedResponseBody)
}
}
val contentType = responseBody.contentType()
val charset: Charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
if (!buffer.isProbablyUtf8()) {
"<-- END HTTP (binary ${buffer.size}-byte body omitted)"
if (shouldCheckResponseBodySize && buffer.size > WRITE_LOG_THRESHOLD_BYTES_SIZE) {
"Response size too large: ${buffer.size} bytes \n<-- END HTTP"
} else {
val json = if (contentLength != 0L) {
buffer.clone().readString(charset).beautifyJson()
} else {
""
var gzippedLength: Long? = null
if ("gzip".equals(responseHeaders["Content-Encoding"], ignoreCase = true)) {
gzippedLength = buffer.size
GzipSource(buffer.clone()).use { gzippedResponseBody ->
buffer = Buffer()
buffer.writeAll(gzippedResponseBody)
}
}
val end = if (gzippedLength != null) {
"<-- END HTTP (${buffer.size}-byte, $gzippedLength-gzipped-byte body)"
} else {
"<-- END HTTP (${buffer.size}-byte body)"
}
val contentType = responseBody.contentType()
val charset: Charset = contentType?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
"$json\n$end"
if (!buffer.isProbablyUtf8()) {
"<-- END HTTP (binary ${buffer.size}-byte body omitted)"
} else {
val json = if (contentLength != 0L) {
buffer.clone().readString(charset).beautifyJson()
} else {
""
}
val end = if (gzippedLength != null) {
"<-- END HTTP (${buffer.size}-byte, $gzippedLength-gzipped-byte body)"
} else {
"<-- END HTTP (${buffer.size}-byte body)"
}
"$json\n$end"
}
}
}
@ -166,12 +178,16 @@ class NetworkLogsSaveInterceptor(
saveLogMessage(
"<-- ${response.code}",
spaceBeforeResponseMessage,
response.message,
" ${response.request.url} (${tookMs}ms)\n",
" ${response.request.url.maskSensitiveInfo()} (${tookMs}ms)\n",
message,
)
}
private fun HttpUrl.maskSensitiveInfo(): String {
val url = toString()
return sensitiveUrlMasker?.mask(url) ?: url
}
private fun bodyHasUnknownEncoding(headers: Headers): Boolean {
val contentEncoding = headers["Content-Encoding"] ?: return false
return !contentEncoding.equals("identity", ignoreCase = true) &&
@ -231,6 +247,9 @@ class NetworkLogsSaveInterceptor(
}
private companion object {
const val WRITE_LOG_THRESHOLD_BYTES_SIZE = 2_048_000L
/**
* List of URLs (host + path) for which logging is restricted
*/

View file

@ -14,21 +14,39 @@ class WireMockRedirectInterceptor : Interceptor {
val request = chain.request()
val url = request.url.toString()
val host = request.url.host
val sanitizedOverride = override.trimEnd('/')
if (url.contains(WIREMOCK_REMOTE_URL)) {
val newUrl = url.replace(WIREMOCK_REMOTE_URL, override.trimEnd('/'))
if (host == WIREMOCK_REMOTE_HOST) {
val newUrl = url.replace(WIREMOCK_REMOTE_URL, sanitizedOverride)
TangemLogger.d("WireMockRedirect: $url -> $newUrl")
val newRequest = request.newBuilder()
.url(newUrl)
.build()
return chain.proceed(newRequest)
return chain.proceed(request.newBuilder().url(newUrl).build())
}
if (host in REDIRECTABLE_THIRD_PARTY_HOSTS) {
val newUrl = url.replace("https://$host", "$sanitizedOverride/$host")
TangemLogger.d("WireMockRedirect (3p): $url -> $newUrl")
return chain.proceed(request.newBuilder().url(newUrl).build())
}
return chain.proceed(request)
}
companion object {
private const val WIREMOCK_REMOTE_URL = "[REDACTED_ENV_URL]"
private const val WIREMOCK_REMOTE_HOST = "wiremock.tests-d.com"
private const val WIREMOCK_REMOTE_URL = "https://$WIREMOCK_REMOTE_HOST"
/**
* Upstream hosts that have no other override knob and should be funnelled into WireMock
* when [overriddenBaseUrl] is set. Each matched URL becomes `<override>/<host>/<original-path>`,
* so mock mappings should live under that host-prefixed path in tangem-api-mocks. Matching
* is done against the request's parsed host (exact equality) substring matching would
* incorrectly redirect look-alikes such as `deep-index.moralis.io.evil.example`.
*/
private val REDIRECTABLE_THIRD_PARTY_HOSTS = setOf(
"deep-index.moralis.io",
"solana-gateway.moralis.io",
)
/**
* Override base URL for WireMock requests.

View file

@ -88,6 +88,7 @@ class ApiConfigTest {
appInfoProvider = mockk(),
)
ApiConfig.ID.SurveySparrow -> SurveySparrow(environmentConfig = environmentConfig)
ApiConfig.ID.Auth -> Auth()
}
}
}

View file

@ -130,6 +130,7 @@ internal class ProdApiConfigsManagerTest {
appInfoProvider = appInfoProvider,
)
ApiConfig.ID.SurveySparrow -> SurveySparrow(environmentConfig = environmentConfig)
ApiConfig.ID.Auth -> Auth()
}
}
}
@ -148,9 +149,32 @@ internal class ProdApiConfigsManagerTest {
ApiConfig.ID.News -> createNewsModel()
ApiConfig.ID.GaslessTxService -> createGaslessTxServiceModel()
ApiConfig.ID.SurveySparrow -> createSurveySparrowModel()
ApiConfig.ID.Auth -> createAuthModel()
}
}
private fun createAuthModel(): TestModel {
val environment = when (BuildConfig.BUILD_TYPE) {
MOCKED_BUILD_TYPE -> ApiEnvironment.MOCK
DEBUG_BUILD_TYPE,
INTERNAL_BUILD_TYPE,
-> ApiEnvironment.DEV
EXTERNAL_BUILD_TYPE,
RELEASE_BUILD_TYPE,
-> ApiEnvironment.PROD
else -> error("Unknown build type [${BuildConfig.BUILD_TYPE}]")
}
return TestModel(
id = ApiConfig.ID.Auth,
expected = ApiEnvironmentConfig(
environment = environment,
baseUrl = "http://localhost:8080/",
headers = emptyMap(),
),
)
}
private fun createExpressModel(): TestModel {
val environment = when (BuildConfig.BUILD_TYPE) {
DEBUG_BUILD_TYPE,

View file

@ -0,0 +1,107 @@
package com.tangem.datasource.local.logs
import com.google.common.truth.Truth
import com.tangem.datasource.local.logs.SensitiveUrlMasker.Companion.MASKED_VALUE
import com.tangem.test.core.ProvideTestModels
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.params.ParameterizedTest
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
internal class SensitiveUrlMaskerTest {
@ParameterizedTest
@ProvideTestModels
fun mask(model: TestModel) {
// Arrange
val masker = SensitiveUrlMasker(model.sensitiveValues)
// Act
val actual = masker.mask(model.input)
// Assert
Truth.assertThat(actual).isEqualTo(model.expected)
}
@Test
fun `mask returns url unchanged when no sensitive values provided`() {
// Arrange
val masker = SensitiveUrlMasker(emptyList())
val url = "https://api.tangem.com/v1/cards/abc123"
// Act
val actual = masker.mask(url)
// Assert
Truth.assertThat(actual).isEqualTo(url)
}
@Test
fun `constructor deduplicates input values`() {
// Arrange — same secret repeated; if no dedup, replace would be invoked twice
// (idempotent on already-masked string, but we assert behavior is identical
// to a single-value masker as a smoke-check)
val withDuplicates = SensitiveUrlMasker(listOf("secret123", "secret123", "secret123"))
val withSingle = SensitiveUrlMasker(listOf("secret123"))
val url = "https://api.tangem.com/?key=secret123"
// Act
val withDup = withDuplicates.mask(url)
val withSingleResult = withSingle.mask(url)
// Assert
Truth.assertThat(withDup).isEqualTo(withSingleResult)
Truth.assertThat(withDup).isEqualTo("https://api.tangem.com/?key=$MASKED_VALUE")
}
private fun provideTestModels() = listOf(
TestModel(
input = "https://api.tangem.com/?key=secret123",
sensitiveValues = listOf("secret123"),
expected = "https://api.tangem.com/?key=$MASKED_VALUE",
),
TestModel(
input = "https://api.tangem.com/?a=alpha&b=beta",
sensitiveValues = listOf("alpha", "beta"),
expected = "https://api.tangem.com/?a=$MASKED_VALUE&b=$MASKED_VALUE",
),
TestModel(
input = "https://api.tangem.com/?key=SECRET123",
sensitiveValues = listOf("secret123"),
expected = "https://api.tangem.com/?key=$MASKED_VALUE",
),
TestModel(
input = "https://api.tangem.com/v1/balance",
sensitiveValues = listOf("notInUrl"),
expected = "https://api.tangem.com/v1/balance",
),
TestModel(
input = "https://api.tangem.com/?key=secret123&other=secret123",
sensitiveValues = listOf("secret123"),
expected = "https://api.tangem.com/?key=$MASKED_VALUE&other=$MASKED_VALUE",
),
TestModel(
input = "https://api.tangem.com/v1/cards",
sensitiveValues = emptyList(),
expected = "https://api.tangem.com/v1/cards",
),
// Regression: when one value is a prefix of another, the longer one must be masked first
// regardless of input order, otherwise the suffix leaks (e.g. "my-node-prod" -> "******-prod").
TestModel(
input = "https://my-node-prod.example.com/v1",
sensitiveValues = listOf("my-node", "my-node-prod"),
expected = "https://$MASKED_VALUE.example.com/v1",
),
TestModel(
input = "https://my-node-prod.example.com/v1",
sensitiveValues = listOf("my-node-prod", "my-node"),
expected = "https://$MASKED_VALUE.example.com/v1",
),
)
data class TestModel(
val input: String,
val sensitiveValues: List<String>,
val expected: String,
)
}