From e156fd3ce1d15b22eb7ab6a07ef787c757f7f6ea Mon Sep 17 00:00:00 2001 From: Tangem Date: Fri, 10 Jul 2026 12:44:52 +0400 Subject: [PATCH] Updated on 2026-08-14 --- .../com/tangem/datasource/api/auth/AuthApi.kt | 14 ++--- .../auth/models/request/RegisterApiRequest.kt | 4 +- .../request/WalletRegistrationRequest.kt | 2 +- .../datasource/api/common/config/Auth.kt | 2 +- .../managers/ProdApiConfigsManagerTest.kt | 2 +- .../lib/auth/devicekey/DeviceKeyManager.kt | 29 +++++++-- .../internal/DefaultDeviceKeyManager.kt | 40 +++++++++--- .../internal/DisabledDeviceKeyManager.kt | 10 ++- .../dpop/internal/DefaultDpopProofFactory.kt | 6 +- .../lib/auth/session/AuthErrorResponse.kt | 2 +- .../lib/auth/session/DeviceRegistrar.kt | 9 ++- .../lib/auth/session/SessionTokenRefresher.kt | 13 ++-- .../lib/auth/session/WalletRegistrar.kt | 7 +-- .../internal/DefaultDeviceRegistrar.kt | 6 +- .../internal/DefaultSessionTokenRefresher.kt | 4 +- .../internal/DefaultWalletRegistrar.kt | 2 +- .../internal/DefaultDeviceKeyManagerTest.kt | 63 ++++++++++++++++--- .../internal/DefaultDpopProofFactoryTest.kt | 4 +- .../internal/DefaultDeviceRegistrarTest.kt | 22 +++---- .../DefaultSessionTokenRefresherTest.kt | 10 +-- .../internal/DefaultWalletRegistrarTest.kt | 10 +-- 21 files changed, 178 insertions(+), 83 deletions(-) diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt index 68aec6cd5a..ec6a549793 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/AuthApi.kt @@ -21,7 +21,7 @@ interface AuthApi { * * Generates a nonce bound to the device public key for the device registration flow. */ - @POST("api/v1/auth/nonce/device") + @POST("api/authentication/v1/mobile/nonce/device") suspend fun requestDeviceNonce(@Body request: NonceApiRequest): ApiResponse /** @@ -30,7 +30,7 @@ interface AuthApi { * Registers a new device using its hardware-backed public key and issues the initial * session token pair. Called once per app install. */ - @POST("api/v1/auth/register") + @POST("api/authentication/v1/mobile/register") suspend fun registerDevice(@Body request: RegisterApiRequest): ApiResponse /** @@ -38,7 +38,7 @@ interface AuthApi { * * Generates a nonce bound to the device public key for the authentication flow. */ - @POST("api/v1/auth/nonce/auth") + @POST("api/authentication/v1/mobile/nonce/auth") suspend fun requestAuthNonce(@Body request: NonceApiRequest): ApiResponse /** @@ -48,7 +48,7 @@ interface AuthApi { * JWT access token with bound `walletIds[]` and risk tier. All subsequent auth after * registration uses this endpoint. */ - @POST("api/v1/auth/authenticate") + @POST("api/authentication/v1/mobile/authenticate") suspend fun authenticate(@Body request: AuthApiRequest): ApiResponse /** @@ -58,7 +58,7 @@ interface AuthApi { * 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") + @POST("api/authentication/v1/mobile/token/refresh") @RequiresDpopProof suspend fun refresh(@Body request: RefreshApiRequest): ApiResponse @@ -67,7 +67,7 @@ interface AuthApi { * * Generates a nonce bound to the device public key for the wallet registration flow. */ - @POST("api/v1/auth/nonce/wallet") + @POST("api/authentication/v1/mobile/nonce/wallet") suspend fun requestWalletNonce(@Body request: NonceApiRequest): ApiResponse /** @@ -77,7 +77,7 @@ interface AuthApi { * wallet is bound as COLD (card-backed); otherwise it is registered as a MOBILE (hot) wallet. * Returns refreshed session tokens reflecting the updated wallet list. */ - @POST("api/v1/auth/wallet") + @POST("api/authentication/v1/mobile/wallet/register") @RequiresDpopProof suspend fun registerWallet(@Body request: WalletRegistrationRequest): ApiResponse } \ No newline at end of file diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/RegisterApiRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/RegisterApiRequest.kt index 78cbb232dd..9a94fa4a23 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/RegisterApiRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/RegisterApiRequest.kt @@ -6,7 +6,7 @@ import com.squareup.moshi.JsonClass /** * Registration request — registers a new device and establishes initial trust. * - * Posted to `POST /api/v1/auth/register`; on success the server returns + * On success the server returns * [com.tangem.datasource.api.auth.models.response.TokenApiResponse] (the initial session token pair). */ @JsonClass(generateAdapter = true) @@ -22,7 +22,7 @@ data class RegisterApiRequest( data class RegisterPayload( /** Base64-encoded EC public key of the device. */ @Json(name = "devicePublicKey") val devicePublicKey: String, - /** Deciphered nonce value from the `/api/v1/auth/nonce/device` endpoint. */ + /** Deciphered nonce value from the device nonce endpoint. */ @Json(name = "nonce") val nonce: String, /** Platform attestation token (Play Integrity / App Attest). Optional; backend accepts `null`. */ @Json(name = "attestationToken") val attestationToken: String?, diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/WalletRegistrationRequest.kt b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/WalletRegistrationRequest.kt index 8e9ce11c2e..440df2ca50 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/WalletRegistrationRequest.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/auth/models/request/WalletRegistrationRequest.kt @@ -12,7 +12,7 @@ import com.squareup.moshi.JsonClass */ @JsonClass(generateAdapter = true) data class WalletRegistrationRequest( - /** Deciphered nonce value from `/api/v1/auth/nonce/wallet`. */ + /** Deciphered nonce value from the wallet nonce endpoint. */ @Json(name = "nonce") val nonce: String, /** * Wallet identifier — Base64-encoded diff --git a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Auth.kt b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Auth.kt index 5f97c5fa26..d8420df7a8 100644 --- a/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Auth.kt +++ b/core/datasource/src/main/java/com/tangem/datasource/api/common/config/Auth.kt @@ -43,6 +43,6 @@ internal class Auth : ApiConfig() { private companion object { private const val DEV_BASE_URL = "[REDACTED_ENV_URL]" - private const val PROD_BASE_URL = "https://authentication.tangem.org/" + private const val PROD_BASE_URL = "https://api.tangem.org/" } } \ No newline at end of file diff --git a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt index cfe9d5fd71..1447393d58 100644 --- a/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt +++ b/core/datasource/src/test/kotlin/com/tangem/datasource/api/common/config/managers/ProdApiConfigsManagerTest.kt @@ -171,7 +171,7 @@ internal class ProdApiConfigsManagerTest { expected = ApiEnvironmentConfig( environment = environment, baseUrl = when (environment) { - ApiEnvironment.PROD -> "https://authentication.tangem.org/" + ApiEnvironment.PROD -> "https://api.tangem.org/" else -> "[REDACTED_ENV_URL]" }, headers = emptyMap(), diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/DeviceKeyManager.kt b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/DeviceKeyManager.kt index 23e34014bb..e613173925 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/DeviceKeyManager.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/DeviceKeyManager.kt @@ -15,13 +15,34 @@ interface DeviceKeyManager { */ suspend fun generateIfMissing(): Boolean - /** Raw uncompressed public key (0x04 || x || y), or [arrow.core.None] if it cannot be read. */ - suspend fun getPublicKey(): Option + /** + * X.509 `SubjectPublicKeyInfo` (DER) encoding of the device public key — the form the auth + * service parses via `X509EncodedKeySpec`. Use this for the `devicePublicKey` field of auth + * requests (nonce / register / authenticate) and for signed-payload canonicalisation. + * @return the SPKI-encoded key, or [arrow.core.None] if it cannot be read. + */ + suspend fun getPublicKeyEncoded(): Option /** - * Signs [data] with SHA256withECDSA using the device private key. - * @return raw 64-byte signature (r || s), each component zero-padded to 32 bytes + * Raw uncompressed EC point (0x04 || x || y, 65 bytes) of the device public key. Use this to + * build the DPoP proof JWK, where the `x` / `y` coordinates are sliced out directly. + * @return the raw point, or [arrow.core.None] if it cannot be read. + */ + suspend fun getPublicKeyRawPoint(): Option + + /** + * Signs [data] with SHA256withECDSA and returns the signature as raw 64-byte `r || s` + * (each component zero-padded to 32 bytes) — the form JOSE/JWS (ES256) expects. Use this for + * the DPoP proof. * @throws DeviceKeySigningException if signing fails */ suspend fun sign(data: ByteArray): ByteArray + + /** + * Signs [data] with SHA256withECDSA and returns the signature in ASN.1 **DER** form + * (`SEQUENCE { INTEGER r, INTEGER s }`) — the form `java.security.Signature.verify` expects. + * Use this for the `/register` and `/authenticate` payload signature the auth service verifies. + * @throws DeviceKeySigningException if signing fails + */ + suspend fun signDer(data: ByteArray): ByteArray } \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DefaultDeviceKeyManager.kt b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DefaultDeviceKeyManager.kt index a08c21c2e6..f6d9faa6bf 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DefaultDeviceKeyManager.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DefaultDeviceKeyManager.kt @@ -34,28 +34,43 @@ internal class DefaultDeviceKeyManager( } } - override suspend fun getPublicKey(): Option = withContext(dispatchers.io) { + override suspend fun getPublicKeyEncoded(): Option = withContext(dispatchers.io) { Option.catch( recover = { e -> TangemLogger.e("Failed to get device public key", e) None }, - f = ::getPublicKeyBytes, + f = ::getEncodedPublicKeyBytes, + ) + } + + override suspend fun getPublicKeyRawPoint(): Option = withContext(dispatchers.io) { + Option.catch( + recover = { e -> + TangemLogger.e("Failed to get device public key", e) + None + }, + f = ::getPublicKeyRawPointBytes, ) } override suspend fun sign(data: ByteArray): ByteArray = withContext(dispatchers.io) { - try { + Secp256r1.toByte64(computeDerSignature(data)) + } + + override suspend fun signDer(data: ByteArray): ByteArray = withContext(dispatchers.io) { + computeDerSignature(data) + } + + private fun computeDerSignature(data: ByteArray): ByteArray { + return try { val privateKey = keyStore.getKey(KEY_ALIAS, null) ?: throw DeviceKeySigningException("Device key not found") - val signature = Signature.getInstance(SIGNATURE_ALGORITHM).apply { + Signature.getInstance(SIGNATURE_ALGORITHM).apply { initSign(privateKey as java.security.PrivateKey) update(data) - } - - val derSignature = signature.sign() - Secp256r1.toByte64(derSignature) + }.sign() } catch (e: DeviceKeySigningException) { TangemLogger.e("Device key signing failed", e) throw e @@ -101,10 +116,15 @@ internal class DefaultDeviceKeyManager( return builder.build() } - private fun getPublicKeyBytes(): ByteArray { + /** X.509 `SubjectPublicKeyInfo` (DER) encoding — sent as `devicePublicKey` and parsed server-side. */ + private fun getEncodedPublicKeyBytes(): ByteArray { val cert = checkNotNull(keyStore.getCertificate(KEY_ALIAS)) { "Device key not found" } + return cert.publicKey.encoded + } - val encoded = cert.publicKey.encoded + /** Raw uncompressed EC point (0x04 || x || y) sliced from the SPKI encoding — used for the DPoP JWK. */ + private fun getPublicKeyRawPointBytes(): ByteArray { + val encoded = getEncodedPublicKeyBytes() check(encoded.size >= EC_UNCOMPRESSED_POINT_SIZE) { "Invalid encoded public key: expected at least $EC_UNCOMPRESSED_POINT_SIZE bytes, got ${encoded.size}" } diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DisabledDeviceKeyManager.kt b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DisabledDeviceKeyManager.kt index 135469b023..5860952810 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DisabledDeviceKeyManager.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/devicekey/internal/DisabledDeviceKeyManager.kt @@ -9,11 +9,19 @@ internal object DisabledDeviceKeyManager : DeviceKeyManager { override suspend fun generateIfMissing(): Boolean = false - override suspend fun getPublicKey(): Option = None + override suspend fun getPublicKeyEncoded(): Option = None + + override suspend fun getPublicKeyRawPoint(): Option = None override suspend fun sign(data: ByteArray): ByteArray { throw DeviceKeySigningException( "DeviceKeyManager is disabled: feature toggle is off or keystore is unavailable", ) } + + override suspend fun signDer(data: ByteArray): ByteArray { + throw DeviceKeySigningException( + "DeviceKeyManager is disabled: feature toggle is off or keystore is unavailable", + ) + } } \ No newline at end of file diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactory.kt b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactory.kt index d46fe19fd4..5000d85cae 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactory.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactory.kt @@ -27,15 +27,15 @@ internal class DefaultDpopProofFactory( override suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option = withContext(dispatchers.default) { - val publicKey = deviceKeyManager.getPublicKey().getOrNull() + val publicKey = deviceKeyManager.getPublicKeyRawPoint().getOrNull() if (publicKey == null) { TangemLogger.e("DPoP proof skipped: device key unavailable") return@withContext None } - // DeviceKeyManager.getPublicKey() guarantees an uncompressed P-256 point - // (0x04 || X(32) || Y(32)) — see DefaultDeviceKeyManager.getPublicKeyBytes. + // getPublicKeyRawPoint() guarantees an uncompressed P-256 point + // (0x04 || X(32) || Y(32)) — see DefaultDeviceKeyManager.getPublicKeyRawPointBytes. val x = publicKey.copyOfRange(fromIndex = 1, toIndex = 1 + COORDINATE_SIZE) val y = publicKey.copyOfRange(fromIndex = 1 + COORDINATE_SIZE, toIndex = 1 + 2 * COORDINATE_SIZE) diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/AuthErrorResponse.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/AuthErrorResponse.kt index e895e6c289..896792d682 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/AuthErrorResponse.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/AuthErrorResponse.kt @@ -17,7 +17,7 @@ data class AuthErrorResponse( @SerialName("status") val status: Int, /** Human-readable explanation. */ @SerialName("detail") val detail: String? = null, - /** URI reference to this occurrence (e.g. `"/api/v1/auth/refresh"`). */ + /** URI reference to this occurrence (the request path that produced the error). */ @SerialName("instance") val instance: String? = null, /** Application-specific error code. */ @SerialName("code") val code: String? = null, diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/DeviceRegistrar.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/DeviceRegistrar.kt index 02aa26a01f..9523e2ce08 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/DeviceRegistrar.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/DeviceRegistrar.kt @@ -6,13 +6,12 @@ import arrow.core.Either * Registers the device with the Tangem Auth Service and persists the initial session tokens. * * Idempotent and safe to call on every app launch: - * - on first run, fetches a ciphered nonce from `POST /api/v1/auth/nonce/device`, decrypts it - * with the app's RSA private key, signs a `RegisterPayload` with the device key, posts it to - * `POST /api/v1/auth/register`, persists the resulting `SessionTokens` and flips the - * "device registered" flag in `AppPreferencesStore`, + * - on first run, fetches a ciphered nonce, decrypts it with the app's RSA private key, signs a + * `RegisterPayload` with the device key, registers the device, persists the resulting + * `SessionTokens` and flips the "device registered" flag in `AppPreferencesStore`, * - on subsequent runs, sees the flag and short-circuits without any network traffic. * - * Tokens returned by `/register` are not surfaced to callers — they're written to + * Tokens returned by registration are not surfaced to callers — they're written to * `SessionTokensStore` and accessed from there. The result type carries only success/failure * so callers can log/report transient errors. * diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokenRefresher.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokenRefresher.kt index 04be67d148..a8e44a0689 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokenRefresher.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/SessionTokenRefresher.kt @@ -10,13 +10,12 @@ import arrow.core.Either * the entire session chain (SR-8 / RFC 9449 §5). * * Refresh strategy: - * 1. Call `/api/v1/auth/refresh` with the stored refresh token when it is present and unexpired. - * 2. On 401 from `/refresh` (revoked / replayed / expired refresh token), fall back to full - * re-authentication via `/api/v1/auth/nonce/auth` + `/api/v1/auth/authenticate` signed by the - * device key. - * 3. On 403 from `/refresh` (RED tier — device blocked server-side), return - * [SessionRefreshError.DeviceBlocked] without trying `/authenticate` (it would also 403). - * 4. On 401/403 from `/authenticate`, clear the session store and return + * 1. Refresh with the stored refresh token when it is present and unexpired. + * 2. On 401 (revoked / replayed / expired refresh token), fall back to full re-authentication + * (request an auth nonce and authenticate) signed by the device key. + * 3. On 403 (RED tier — device blocked server-side), return [SessionRefreshError.DeviceBlocked] + * without trying to re-authenticate (it would also 403). + * 4. On 401/403 during re-authentication, clear the session store and return * [SessionRefreshError.SessionRevoked] — the device must be re-registered. */ interface SessionTokenRefresher { diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/WalletRegistrar.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/WalletRegistrar.kt index 1ffa5ad68c..29ff1ea49a 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/WalletRegistrar.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/WalletRegistrar.kt @@ -3,9 +3,8 @@ 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. + * Binds a wallet to the already-registered device with the Tangem Auth Service, 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 @@ -17,7 +16,7 @@ import arrow.core.Either * 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 + * Tokens returned by wallet registration are written to `SessionTokensStore`, not surfaced to callers — the * result type carries only success/failure so callers can log transient errors. */ interface WalletRegistrar { diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrar.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrar.kt index af14515777..54e67d9a97 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrar.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrar.kt @@ -41,7 +41,7 @@ internal class DefaultDeviceRegistrar( override suspend fun register(): Either = withContext(dispatchers.io) { // `Mutex` guards against the unlikely case of two concurrent callers passing the - // already-registered check together and consuming the same `/nonce/device` value twice. + // already-registered check together and consuming the same device nonce twice. mutex.withLock { runRegister() } } @@ -57,7 +57,7 @@ internal class DefaultDeviceRegistrar( TangemLogger.i("Starting device registration") - val devicePublicKey = deviceKeyManager.getPublicKey().getOrNull() + val devicePublicKey = deviceKeyManager.getPublicKeyEncoded().getOrNull() ?: raise(DeviceRegistrationError.DeviceKeyUnavailable) val devicePublicKeyBase64 = devicePublicKey.toBase64NoWrap() @@ -86,7 +86,7 @@ internal class DefaultDeviceRegistrar( metadata = signedRequestPayload.deviceMetadata, ) val signature = try { - deviceKeyManager.sign(signedRequestPayload.canonicalize(payload)).toBase64NoWrap() + deviceKeyManager.signDer(signedRequestPayload.canonicalize(payload)).toBase64NoWrap() } catch (e: Exception) { TangemLogger.e("Failed to sign device-registration payload", e) raise(DeviceRegistrationError.SigningFailed(e)) diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresher.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresher.kt index 17e2eff7c9..5022d68dfa 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresher.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresher.kt @@ -131,7 +131,7 @@ internal class DefaultSessionTokenRefresher( private suspend fun runAuthenticate(): Either = either { TangemLogger.i("Starting /authenticate") - val devicePublicKey = deviceKeyManager.getPublicKey().getOrNull() + val devicePublicKey = deviceKeyManager.getPublicKeyEncoded().getOrNull() ?: raise(SessionRefreshError.DeviceKeyUnavailable) val devicePublicKeyBase64 = devicePublicKey.toBase64NoWrap() @@ -160,7 +160,7 @@ internal class DefaultSessionTokenRefresher( metadata = signedRequestPayload.deviceMetadata, ) val signature = try { - deviceKeyManager.sign(signedRequestPayload.canonicalize(payload)).toBase64NoWrap() + deviceKeyManager.signDer(signedRequestPayload.canonicalize(payload)).toBase64NoWrap() } catch (e: Exception) { TangemLogger.e("Failed to sign authentication payload", e) raise(SessionRefreshError.SigningFailed(e)) diff --git a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrar.kt b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrar.kt index 416f6fa95e..3181a5d29f 100644 --- a/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrar.kt +++ b/libs/auth/src/main/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrar.kt @@ -65,7 +65,7 @@ internal class DefaultWalletRegistrar( TangemLogger.i("Starting wallet registration") - val devicePublicKey = deviceKeyManager.getPublicKey().getOrNull() + val devicePublicKey = deviceKeyManager.getPublicKeyEncoded().getOrNull() ?: raise(WalletRegistrationError.DeviceKeyUnavailable) val devicePublicKeyBase64 = devicePublicKey.toBase64NoWrap() diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/devicekey/internal/DefaultDeviceKeyManagerTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/devicekey/internal/DefaultDeviceKeyManagerTest.kt index 8f7cf3187d..ee43148829 100644 --- a/libs/auth/src/test/java/com/tangem/lib/auth/devicekey/internal/DefaultDeviceKeyManagerTest.kt +++ b/libs/auth/src/test/java/com/tangem/lib/auth/devicekey/internal/DefaultDeviceKeyManagerTest.kt @@ -62,7 +62,7 @@ class DefaultDeviceKeyManagerTest { } @Test - fun `getPublicKey returns last 65 bytes from encoded key`() = runTest { + fun `getPublicKeyEncoded returns full X509 SPKI encoding`() = runTest { val rawPoint = ByteArray(65) { (it + 1).toByte() }.apply { this[0] = 0x04 } val x509Header = ByteArray(26) { 0x30 } val encoded = x509Header + rawPoint @@ -74,22 +74,49 @@ class DefaultDeviceKeyManagerTest { every { cert.publicKey } returns publicKey every { keyStore.getCertificate(KEY_ALIAS) } returns cert - val result = manager.getPublicKey() + val result = manager.getPublicKeyEncoded() - assertThat(result.getOrNull()).isEqualTo(rawPoint) + assertThat(result.getOrNull()).isEqualTo(encoded) } @Test - fun `getPublicKey returns None when certificate not found`() = runTest { + fun `getPublicKeyEncoded returns None when certificate not found`() = runTest { every { keyStore.getCertificate(KEY_ALIAS) } returns null - val result = manager.getPublicKey() + val result = manager.getPublicKeyEncoded() assertThat(result).isEqualTo(None) } @Test - fun `getPublicKey returns None when point prefix is not uncompressed`() = runTest { + fun `getPublicKeyRawPoint returns last 65 bytes from encoded key`() = runTest { + val rawPoint = ByteArray(65) { (it + 1).toByte() }.apply { this[0] = 0x04 } + val x509Header = ByteArray(26) { 0x30 } + val encoded = x509Header + rawPoint + + val publicKey = mockk() + every { publicKey.encoded } returns encoded + + val cert = mockk() + every { cert.publicKey } returns publicKey + every { keyStore.getCertificate(KEY_ALIAS) } returns cert + + val result = manager.getPublicKeyRawPoint() + + assertThat(result.getOrNull()).isEqualTo(rawPoint) + } + + @Test + fun `getPublicKeyRawPoint returns None when certificate not found`() = runTest { + every { keyStore.getCertificate(KEY_ALIAS) } returns null + + val result = manager.getPublicKeyRawPoint() + + assertThat(result).isEqualTo(None) + } + + @Test + fun `getPublicKeyRawPoint returns None when point prefix is not uncompressed`() = runTest { val rawPoint = ByteArray(65) { (it + 1).toByte() }.apply { this[0] = 0x02 } val x509Header = ByteArray(26) { 0x30 } val encoded = x509Header + rawPoint @@ -101,7 +128,7 @@ class DefaultDeviceKeyManagerTest { every { cert.publicKey } returns publicKey every { keyStore.getCertificate(KEY_ALIAS) } returns cert - val result = manager.getPublicKey() + val result = manager.getPublicKeyRawPoint() assertThat(result).isEqualTo(None) } @@ -130,6 +157,28 @@ class DefaultDeviceKeyManagerTest { assertThat(result.copyOfRange(32, 64)).isEqualTo(s) } + @Test + fun `signDer returns the DER signature unchanged`() = runTest { + val data = "test data".toByteArray() + val r = ByteArray(32) { 0x01 } + val s = ByteArray(32) { 0x02 } + val derSignature = buildDer(r, s) + + val privateKey = mockk() + every { keyStore.getKey(KEY_ALIAS, null) } returns privateKey + + val javaSig = mockk() + every { javaSig.initSign(privateKey) } returns Unit + every { javaSig.update(data) } returns Unit + every { javaSig.sign() } returns derSignature + + mockkSignatureGetInstance(javaSig) + + val result = manager.signDer(data) + + assertThat(result).isEqualTo(derSignature) + } + @Test fun `sign throws DeviceKeySigningException when key not found`() = runTest { every { keyStore.getKey(KEY_ALIAS, null) } returns null diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactoryTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactoryTest.kt index 8dee6ba5f9..274d498f94 100644 --- a/libs/auth/src/test/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactoryTest.kt +++ b/libs/auth/src/test/java/com/tangem/lib/auth/dpop/internal/DefaultDpopProofFactoryTest.kt @@ -67,7 +67,7 @@ class DefaultDpopProofFactoryTest { mockkStatic(UUID::class) every { UUID.randomUUID() } returns fixedJti - coEvery { deviceKeyManager.getPublicKey() } returns Some(devicePublicKey) + coEvery { deviceKeyManager.getPublicKeyRawPoint() } returns Some(devicePublicKey) coEvery { deviceKeyManager.sign(any()) } returns signatureBytes factory = DefaultDpopProofFactory( @@ -133,7 +133,7 @@ class DefaultDpopProofFactoryTest { @Test fun `create returns None when device key unavailable`() = runTest { - coEvery { deviceKeyManager.getPublicKey() } returns None + coEvery { deviceKeyManager.getPublicKeyRawPoint() } returns None val result = factory.create("POST", "https://example.com", null) diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrarTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrarTest.kt index b523254315..82aaf805c6 100644 --- a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrarTest.kt +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultDeviceRegistrarTest.kt @@ -107,7 +107,7 @@ class DefaultDeviceRegistrarTest { @Test fun `register returns DeviceKeyUnavailable when keystore has no key`() = runTest { - coEvery { deviceKeyManager.getPublicKey() } returns None + coEvery { deviceKeyManager.getPublicKeyEncoded() } returns None val result = registrar.register() @@ -119,7 +119,7 @@ class DefaultDeviceRegistrarTest { @Test fun `register surfaces nonce-endpoint API error`() = runTest { - coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) + coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65)) @Suppress("UNCHECKED_CAST") coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Error( cause = ApiResponseError.HttpException( @@ -138,7 +138,7 @@ class DefaultDeviceRegistrarTest { @Test fun `register returns NonceDecryptionFailed when decryptor throws`() = runTest { - coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) + coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65)) coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success( data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"), ) @@ -152,12 +152,12 @@ class DefaultDeviceRegistrarTest { @Test fun `register returns SigningFailed when signing throws`() = runTest { - coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) + coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65)) coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success( data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"), ) coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted" - coEvery { deviceKeyManager.sign(any()) } throws IllegalStateException("Keystore offline") + coEvery { deviceKeyManager.signDer(any()) } throws IllegalStateException("Keystore offline") val result = registrar.register() @@ -167,12 +167,12 @@ class DefaultDeviceRegistrarTest { @Test fun `register surfaces register-endpoint API error and does not touch tokens or flag`() = runTest { - coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) + coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65)) coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success( data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"), ) coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted" - coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64) + coEvery { deviceKeyManager.signDer(any()) } returns ByteArray(64) @Suppress("UNCHECKED_CAST") coEvery { authApi.registerDevice(any()) } returns ApiResponse.Error( cause = ApiResponseError.HttpException( @@ -191,12 +191,12 @@ class DefaultDeviceRegistrarTest { @Test fun `register treats 409 Conflict as success, sets flag without persisting tokens`() = runTest { - coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) + coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65)) coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success( data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"), ) coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted" - coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64) + coEvery { deviceKeyManager.signDer(any()) } returns ByteArray(64) @Suppress("UNCHECKED_CAST") coEvery { authApi.registerDevice(any()) } returns ApiResponse.Error( cause = ApiResponseError.HttpException( @@ -227,12 +227,12 @@ class DefaultDeviceRegistrarTest { } private fun stubHappyPath() { - coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) + coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65)) coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success( data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"), ) coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted" - coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64) + coEvery { deviceKeyManager.signDer(any()) } returns ByteArray(64) coEvery { authApi.registerDevice(any()) } returns ApiResponse.Success( data = TokenApiResponse( accessToken = "fresh-access", diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresherTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresherTest.kt index 7bc6838580..730898ec88 100644 --- a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresherTest.kt +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultSessionTokenRefresherTest.kt @@ -166,12 +166,12 @@ class DefaultSessionTokenRefresherTest { fun `refresh clears store when authenticate returns 403`() = runTest { coEvery { store.get() } returns None - coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) + coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65)) coEvery { authApi.requestAuthNonce(any()) } returns ApiResponse.Success( data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"), ) coEvery { nonceDecryptor.decryptNonce("abc") } returns "nonce-decrypted" - coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64) + coEvery { deviceKeyManager.signDer(any()) } returns ByteArray(64) @Suppress("UNCHECKED_CAST") coEvery { authApi.authenticate(any()) } returns ApiResponse.Error( cause = ApiResponseError.HttpException( @@ -191,7 +191,7 @@ class DefaultSessionTokenRefresherTest { @Test fun `refresh returns DeviceKeyUnavailable when authenticate fallback has no key`() = runTest { coEvery { store.get() } returns None - coEvery { deviceKeyManager.getPublicKey() } returns None + coEvery { deviceKeyManager.getPublicKeyEncoded() } returns None val result = refresher.refresh() @@ -343,12 +343,12 @@ class DefaultSessionTokenRefresherTest { } private fun stubAuthenticateHappyPath() { - coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) + coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65)) coEvery { authApi.requestAuthNonce(any()) } returns ApiResponse.Success( data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"), ) coEvery { nonceDecryptor.decryptNonce("abc") } returns "nonce-decrypted" - coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64) + coEvery { deviceKeyManager.signDer(any()) } returns ByteArray(64) coEvery { authApi.authenticate(any()) } returns ApiResponse.Success( data = TokenApiResponse( accessToken = "post-auth-access", diff --git a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrarTest.kt b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrarTest.kt index c3507d3179..59947c9baf 100644 --- a/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrarTest.kt +++ b/libs/auth/src/test/java/com/tangem/lib/auth/session/internal/DefaultWalletRegistrarTest.kt @@ -167,7 +167,7 @@ class DefaultWalletRegistrarTest { @Test fun `register returns DeviceKeyUnavailable when keystore has no key`() = runTest { - coEvery { deviceKeyManager.getPublicKey() } returns None + coEvery { deviceKeyManager.getPublicKeyEncoded() } returns None val result = registrar.register(WALLET_ID, mobileSigner) @@ -178,7 +178,7 @@ class DefaultWalletRegistrarTest { @Test fun `register surfaces nonce-endpoint API error`() = runTest { - coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) + coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65)) @Suppress("UNCHECKED_CAST") coEvery { authApi.requestWalletNonce(any()) } returns ApiResponse.Error( cause = ApiResponseError.HttpException( @@ -197,7 +197,7 @@ class DefaultWalletRegistrarTest { @Test fun `register returns NonceDecryptionFailed when decryptor throws`() = runTest { - coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) + coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65)) coEvery { authApi.requestWalletNonce(any()) } returns nonceSuccess() coEvery { nonceDecryptor.decryptNonce("abc") } throws IllegalStateException("OAEP failed") @@ -209,7 +209,7 @@ class DefaultWalletRegistrarTest { @Test fun `register returns SigningFailed when signer throws (e g cancelled NFC or biometric)`() = runTest { - coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) + coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65)) coEvery { authApi.requestWalletNonce(any()) } returns nonceSuccess() coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted" val failingSigner = WalletSigner { throw IllegalStateException("user cancelled") } @@ -272,7 +272,7 @@ class DefaultWalletRegistrarTest { } private fun stubHappyPath() { - coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) + coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65)) coEvery { authApi.requestWalletNonce(any()) } returns nonceSuccess() coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted" }