Updated on 2026-08-14

This commit is contained in:
Tangem 2026-07-10 12:44:52 +04:00
parent d2fd1c94b2
commit e156fd3ce1
21 changed files with 178 additions and 83 deletions

View file

@ -21,7 +21,7 @@ interface AuthApi {
* *
* Generates a nonce bound to the device public key for the device registration flow. * 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<NonceApiResponse> suspend fun requestDeviceNonce(@Body request: NonceApiRequest): ApiResponse<NonceApiResponse>
/** /**
@ -30,7 +30,7 @@ interface AuthApi {
* Registers a new device using its hardware-backed public key and issues the initial * Registers a new device using its hardware-backed public key and issues the initial
* session token pair. Called once per app install. * 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<TokenApiResponse> suspend fun registerDevice(@Body request: RegisterApiRequest): ApiResponse<TokenApiResponse>
/** /**
@ -38,7 +38,7 @@ interface AuthApi {
* *
* Generates a nonce bound to the device public key for the authentication flow. * 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<NonceApiResponse> suspend fun requestAuthNonce(@Body request: NonceApiRequest): ApiResponse<NonceApiResponse>
/** /**
@ -48,7 +48,7 @@ interface AuthApi {
* JWT access token with bound `walletIds[]` and risk tier. All subsequent auth after * JWT access token with bound `walletIds[]` and risk tier. All subsequent auth after
* registration uses this endpoint. * registration uses this endpoint.
*/ */
@POST("api/v1/auth/authenticate") @POST("api/authentication/v1/mobile/authenticate")
suspend fun authenticate(@Body request: AuthApiRequest): ApiResponse<TokenApiResponse> suspend fun authenticate(@Body request: AuthApiRequest): ApiResponse<TokenApiResponse>
/** /**
@ -58,7 +58,7 @@ interface AuthApi {
* with family-based reuse detection replaying a consumed token revokes the entire token * 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`). * 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 @RequiresDpopProof
suspend fun refresh(@Body request: RefreshApiRequest): ApiResponse<TokenApiResponse> suspend fun refresh(@Body request: RefreshApiRequest): ApiResponse<TokenApiResponse>
@ -67,7 +67,7 @@ interface AuthApi {
* *
* Generates a nonce bound to the device public key for the wallet registration flow. * 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<NonceApiResponse> suspend fun requestWalletNonce(@Body request: NonceApiRequest): ApiResponse<NonceApiResponse>
/** /**
@ -77,7 +77,7 @@ interface AuthApi {
* wallet is bound as COLD (card-backed); otherwise it is registered as a MOBILE (hot) wallet. * 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. * Returns refreshed session tokens reflecting the updated wallet list.
*/ */
@POST("api/v1/auth/wallet") @POST("api/authentication/v1/mobile/wallet/register")
@RequiresDpopProof @RequiresDpopProof
suspend fun registerWallet(@Body request: WalletRegistrationRequest): ApiResponse<TokenApiResponse> suspend fun registerWallet(@Body request: WalletRegistrationRequest): ApiResponse<TokenApiResponse>
} }

View file

@ -6,7 +6,7 @@ import com.squareup.moshi.JsonClass
/** /**
* Registration request registers a new device and establishes initial trust. * 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). * [com.tangem.datasource.api.auth.models.response.TokenApiResponse] (the initial session token pair).
*/ */
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
@ -22,7 +22,7 @@ data class RegisterApiRequest(
data class RegisterPayload( data class RegisterPayload(
/** Base64-encoded EC public key of the device. */ /** Base64-encoded EC public key of the device. */
@Json(name = "devicePublicKey") val devicePublicKey: String, @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, @Json(name = "nonce") val nonce: String,
/** Platform attestation token (Play Integrity / App Attest). Optional; backend accepts `null`. */ /** Platform attestation token (Play Integrity / App Attest). Optional; backend accepts `null`. */
@Json(name = "attestationToken") val attestationToken: String?, @Json(name = "attestationToken") val attestationToken: String?,

View file

@ -12,7 +12,7 @@ import com.squareup.moshi.JsonClass
*/ */
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class WalletRegistrationRequest( 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, @Json(name = "nonce") val nonce: String,
/** /**
* Wallet identifier Base64-encoded * Wallet identifier Base64-encoded

View file

@ -43,6 +43,6 @@ internal class Auth : ApiConfig() {
private companion object { private companion object {
private const val DEV_BASE_URL = "[REDACTED_ENV_URL]" 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/"
} }
} }

View file

@ -171,7 +171,7 @@ internal class ProdApiConfigsManagerTest {
expected = ApiEnvironmentConfig( expected = ApiEnvironmentConfig(
environment = environment, environment = environment,
baseUrl = when (environment) { baseUrl = when (environment) {
ApiEnvironment.PROD -> "https://authentication.tangem.org/" ApiEnvironment.PROD -> "https://api.tangem.org/"
else -> "[REDACTED_ENV_URL]" else -> "[REDACTED_ENV_URL]"
}, },
headers = emptyMap(), headers = emptyMap(),

View file

@ -15,13 +15,34 @@ interface DeviceKeyManager {
*/ */
suspend fun generateIfMissing(): Boolean suspend fun generateIfMissing(): Boolean
/** Raw uncompressed public key (0x04 || x || y), or [arrow.core.None] if it cannot be read. */ /**
suspend fun getPublicKey(): Option<ByteArray> * 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<ByteArray>
/** /**
* Signs [data] with SHA256withECDSA using the device private key. * Raw uncompressed EC point (0x04 || x || y, 65 bytes) of the device public key. Use this to
* @return raw 64-byte signature (r || s), each component zero-padded to 32 bytes * 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<ByteArray>
/**
* 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 * @throws DeviceKeySigningException if signing fails
*/ */
suspend fun sign(data: ByteArray): ByteArray 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
} }

View file

@ -34,28 +34,43 @@ internal class DefaultDeviceKeyManager(
} }
} }
override suspend fun getPublicKey(): Option<ByteArray> = withContext(dispatchers.io) { override suspend fun getPublicKeyEncoded(): Option<ByteArray> = withContext(dispatchers.io) {
Option.catch( Option.catch(
recover = { e -> recover = { e ->
TangemLogger.e("Failed to get device public key", e) TangemLogger.e("Failed to get device public key", e)
None None
}, },
f = ::getPublicKeyBytes, f = ::getEncodedPublicKeyBytes,
)
}
override suspend fun getPublicKeyRawPoint(): Option<ByteArray> = 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) { 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) val privateKey = keyStore.getKey(KEY_ALIAS, null)
?: throw DeviceKeySigningException("Device key not found") ?: throw DeviceKeySigningException("Device key not found")
val signature = Signature.getInstance(SIGNATURE_ALGORITHM).apply { Signature.getInstance(SIGNATURE_ALGORITHM).apply {
initSign(privateKey as java.security.PrivateKey) initSign(privateKey as java.security.PrivateKey)
update(data) update(data)
} }.sign()
val derSignature = signature.sign()
Secp256r1.toByte64(derSignature)
} catch (e: DeviceKeySigningException) { } catch (e: DeviceKeySigningException) {
TangemLogger.e("Device key signing failed", e) TangemLogger.e("Device key signing failed", e)
throw e throw e
@ -101,10 +116,15 @@ internal class DefaultDeviceKeyManager(
return builder.build() 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" } 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) { check(encoded.size >= EC_UNCOMPRESSED_POINT_SIZE) {
"Invalid encoded public key: expected at least $EC_UNCOMPRESSED_POINT_SIZE bytes, got ${encoded.size}" "Invalid encoded public key: expected at least $EC_UNCOMPRESSED_POINT_SIZE bytes, got ${encoded.size}"
} }

View file

@ -9,11 +9,19 @@ internal object DisabledDeviceKeyManager : DeviceKeyManager {
override suspend fun generateIfMissing(): Boolean = false override suspend fun generateIfMissing(): Boolean = false
override suspend fun getPublicKey(): Option<ByteArray> = None override suspend fun getPublicKeyEncoded(): Option<ByteArray> = None
override suspend fun getPublicKeyRawPoint(): Option<ByteArray> = None
override suspend fun sign(data: ByteArray): ByteArray { override suspend fun sign(data: ByteArray): ByteArray {
throw DeviceKeySigningException( throw DeviceKeySigningException(
"DeviceKeyManager is disabled: feature toggle is off or keystore is unavailable", "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",
)
}
} }

View file

@ -27,15 +27,15 @@ internal class DefaultDpopProofFactory(
override suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option<String> = override suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option<String> =
withContext(dispatchers.default) { withContext(dispatchers.default) {
val publicKey = deviceKeyManager.getPublicKey().getOrNull() val publicKey = deviceKeyManager.getPublicKeyRawPoint().getOrNull()
if (publicKey == null) { if (publicKey == null) {
TangemLogger.e("DPoP proof skipped: device key unavailable") TangemLogger.e("DPoP proof skipped: device key unavailable")
return@withContext None return@withContext None
} }
// DeviceKeyManager.getPublicKey() guarantees an uncompressed P-256 point // getPublicKeyRawPoint() guarantees an uncompressed P-256 point
// (0x04 || X(32) || Y(32)) — see DefaultDeviceKeyManager.getPublicKeyBytes. // (0x04 || X(32) || Y(32)) — see DefaultDeviceKeyManager.getPublicKeyRawPointBytes.
val x = publicKey.copyOfRange(fromIndex = 1, toIndex = 1 + COORDINATE_SIZE) val x = publicKey.copyOfRange(fromIndex = 1, toIndex = 1 + COORDINATE_SIZE)
val y = publicKey.copyOfRange(fromIndex = 1 + COORDINATE_SIZE, toIndex = 1 + 2 * COORDINATE_SIZE) val y = publicKey.copyOfRange(fromIndex = 1 + COORDINATE_SIZE, toIndex = 1 + 2 * COORDINATE_SIZE)

View file

@ -17,7 +17,7 @@ data class AuthErrorResponse(
@SerialName("status") val status: Int, @SerialName("status") val status: Int,
/** Human-readable explanation. */ /** Human-readable explanation. */
@SerialName("detail") val detail: String? = null, @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, @SerialName("instance") val instance: String? = null,
/** Application-specific error code. */ /** Application-specific error code. */
@SerialName("code") val code: String? = null, @SerialName("code") val code: String? = null,

View file

@ -6,13 +6,12 @@ import arrow.core.Either
* Registers the device with the Tangem Auth Service and persists the initial session tokens. * Registers the device with the Tangem Auth Service and persists the initial session tokens.
* *
* Idempotent and safe to call on every app launch: * 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 * - on first run, fetches a ciphered nonce, decrypts it with the app's RSA private key, signs a
* with the app's RSA private key, signs a `RegisterPayload` with the device key, posts it to * `RegisterPayload` with the device key, registers the device, persists the resulting
* `POST /api/v1/auth/register`, persists the resulting `SessionTokens` and flips the * `SessionTokens` and flips the "device registered" flag in `AppPreferencesStore`,
* "device registered" flag in `AppPreferencesStore`,
* - on subsequent runs, sees the flag and short-circuits without any network traffic. * - 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 * `SessionTokensStore` and accessed from there. The result type carries only success/failure
* so callers can log/report transient errors. * so callers can log/report transient errors.
* *

View file

@ -10,13 +10,12 @@ import arrow.core.Either
* the entire session chain (SR-8 / RFC 9449 §5). * the entire session chain (SR-8 / RFC 9449 §5).
* *
* Refresh strategy: * Refresh strategy:
* 1. Call `/api/v1/auth/refresh` with the stored refresh token when it is present and unexpired. * 1. 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 * 2. On 401 (revoked / replayed / expired refresh token), fall back to full re-authentication
* re-authentication via `/api/v1/auth/nonce/auth` + `/api/v1/auth/authenticate` signed by the * (request an auth nonce and authenticate) signed by the device key.
* device key. * 3. On 403 (RED tier device blocked server-side), return [SessionRefreshError.DeviceBlocked]
* 3. On 403 from `/refresh` (RED tier device blocked server-side), return * without trying to re-authenticate (it would also 403).
* [SessionRefreshError.DeviceBlocked] without trying `/authenticate` (it would also 403). * 4. On 401/403 during re-authentication, clear the session store and return
* 4. On 401/403 from `/authenticate`, clear the session store and return
* [SessionRefreshError.SessionRevoked] the device must be re-registered. * [SessionRefreshError.SessionRevoked] the device must be re-registered.
*/ */
interface SessionTokenRefresher { interface SessionTokenRefresher {

View file

@ -3,9 +3,8 @@ package com.tangem.lib.auth.session
import arrow.core.Either import arrow.core.Either
/** /**
* Binds a wallet to the already-registered device with the Tangem Auth Service * Binds a wallet to the already-registered device with the Tangem Auth Service, proving wallet
* (`POST /api/v1/auth/wallet`), proving wallet (and, for cold cards, card) ownership over a * (and, for cold cards, card) ownership over a server-issued wallet nonce.
* server-issued wallet nonce.
* *
* Idempotent per wallet: once a `walletId` is registered it is remembered, and subsequent calls * 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 * 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 * fetched before signing (the signature is over the nonce), so the registrar fetches it and hands
* the deciphered bytes to the signer. * 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. * result type carries only success/failure so callers can log transient errors.
*/ */
interface WalletRegistrar { interface WalletRegistrar {

View file

@ -41,7 +41,7 @@ internal class DefaultDeviceRegistrar(
override suspend fun register(): Either<DeviceRegistrationError, Unit> = withContext(dispatchers.io) { override suspend fun register(): Either<DeviceRegistrationError, Unit> = withContext(dispatchers.io) {
// `Mutex` guards against the unlikely case of two concurrent callers passing the // `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() } mutex.withLock { runRegister() }
} }
@ -57,7 +57,7 @@ internal class DefaultDeviceRegistrar(
TangemLogger.i("Starting device registration") TangemLogger.i("Starting device registration")
val devicePublicKey = deviceKeyManager.getPublicKey().getOrNull() val devicePublicKey = deviceKeyManager.getPublicKeyEncoded().getOrNull()
?: raise(DeviceRegistrationError.DeviceKeyUnavailable) ?: raise(DeviceRegistrationError.DeviceKeyUnavailable)
val devicePublicKeyBase64 = devicePublicKey.toBase64NoWrap() val devicePublicKeyBase64 = devicePublicKey.toBase64NoWrap()
@ -86,7 +86,7 @@ internal class DefaultDeviceRegistrar(
metadata = signedRequestPayload.deviceMetadata, metadata = signedRequestPayload.deviceMetadata,
) )
val signature = try { val signature = try {
deviceKeyManager.sign(signedRequestPayload.canonicalize(payload)).toBase64NoWrap() deviceKeyManager.signDer(signedRequestPayload.canonicalize(payload)).toBase64NoWrap()
} catch (e: Exception) { } catch (e: Exception) {
TangemLogger.e("Failed to sign device-registration payload", e) TangemLogger.e("Failed to sign device-registration payload", e)
raise(DeviceRegistrationError.SigningFailed(e)) raise(DeviceRegistrationError.SigningFailed(e))

View file

@ -131,7 +131,7 @@ internal class DefaultSessionTokenRefresher(
private suspend fun runAuthenticate(): Either<SessionRefreshError, SessionTokens> = either { private suspend fun runAuthenticate(): Either<SessionRefreshError, SessionTokens> = either {
TangemLogger.i("Starting /authenticate") TangemLogger.i("Starting /authenticate")
val devicePublicKey = deviceKeyManager.getPublicKey().getOrNull() val devicePublicKey = deviceKeyManager.getPublicKeyEncoded().getOrNull()
?: raise(SessionRefreshError.DeviceKeyUnavailable) ?: raise(SessionRefreshError.DeviceKeyUnavailable)
val devicePublicKeyBase64 = devicePublicKey.toBase64NoWrap() val devicePublicKeyBase64 = devicePublicKey.toBase64NoWrap()
@ -160,7 +160,7 @@ internal class DefaultSessionTokenRefresher(
metadata = signedRequestPayload.deviceMetadata, metadata = signedRequestPayload.deviceMetadata,
) )
val signature = try { val signature = try {
deviceKeyManager.sign(signedRequestPayload.canonicalize(payload)).toBase64NoWrap() deviceKeyManager.signDer(signedRequestPayload.canonicalize(payload)).toBase64NoWrap()
} catch (e: Exception) { } catch (e: Exception) {
TangemLogger.e("Failed to sign authentication payload", e) TangemLogger.e("Failed to sign authentication payload", e)
raise(SessionRefreshError.SigningFailed(e)) raise(SessionRefreshError.SigningFailed(e))

View file

@ -65,7 +65,7 @@ internal class DefaultWalletRegistrar(
TangemLogger.i("Starting wallet registration") TangemLogger.i("Starting wallet registration")
val devicePublicKey = deviceKeyManager.getPublicKey().getOrNull() val devicePublicKey = deviceKeyManager.getPublicKeyEncoded().getOrNull()
?: raise(WalletRegistrationError.DeviceKeyUnavailable) ?: raise(WalletRegistrationError.DeviceKeyUnavailable)
val devicePublicKeyBase64 = devicePublicKey.toBase64NoWrap() val devicePublicKeyBase64 = devicePublicKey.toBase64NoWrap()

View file

@ -62,7 +62,7 @@ class DefaultDeviceKeyManagerTest {
} }
@Test @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 rawPoint = ByteArray(65) { (it + 1).toByte() }.apply { this[0] = 0x04 }
val x509Header = ByteArray(26) { 0x30 } val x509Header = ByteArray(26) { 0x30 }
val encoded = x509Header + rawPoint val encoded = x509Header + rawPoint
@ -74,22 +74,49 @@ class DefaultDeviceKeyManagerTest {
every { cert.publicKey } returns publicKey every { cert.publicKey } returns publicKey
every { keyStore.getCertificate(KEY_ALIAS) } returns cert 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 @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 every { keyStore.getCertificate(KEY_ALIAS) } returns null
val result = manager.getPublicKey() val result = manager.getPublicKeyEncoded()
assertThat(result).isEqualTo(None) assertThat(result).isEqualTo(None)
} }
@Test @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<java.security.PublicKey>()
every { publicKey.encoded } returns encoded
val cert = mockk<Certificate>()
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 rawPoint = ByteArray(65) { (it + 1).toByte() }.apply { this[0] = 0x02 }
val x509Header = ByteArray(26) { 0x30 } val x509Header = ByteArray(26) { 0x30 }
val encoded = x509Header + rawPoint val encoded = x509Header + rawPoint
@ -101,7 +128,7 @@ class DefaultDeviceKeyManagerTest {
every { cert.publicKey } returns publicKey every { cert.publicKey } returns publicKey
every { keyStore.getCertificate(KEY_ALIAS) } returns cert every { keyStore.getCertificate(KEY_ALIAS) } returns cert
val result = manager.getPublicKey() val result = manager.getPublicKeyRawPoint()
assertThat(result).isEqualTo(None) assertThat(result).isEqualTo(None)
} }
@ -130,6 +157,28 @@ class DefaultDeviceKeyManagerTest {
assertThat(result.copyOfRange(32, 64)).isEqualTo(s) 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<PrivateKey>()
every { keyStore.getKey(KEY_ALIAS, null) } returns privateKey
val javaSig = mockk<java.security.Signature>()
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 @Test
fun `sign throws DeviceKeySigningException when key not found`() = runTest { fun `sign throws DeviceKeySigningException when key not found`() = runTest {
every { keyStore.getKey(KEY_ALIAS, null) } returns null every { keyStore.getKey(KEY_ALIAS, null) } returns null

View file

@ -67,7 +67,7 @@ class DefaultDpopProofFactoryTest {
mockkStatic(UUID::class) mockkStatic(UUID::class)
every { UUID.randomUUID() } returns fixedJti every { UUID.randomUUID() } returns fixedJti
coEvery { deviceKeyManager.getPublicKey() } returns Some(devicePublicKey) coEvery { deviceKeyManager.getPublicKeyRawPoint() } returns Some(devicePublicKey)
coEvery { deviceKeyManager.sign(any()) } returns signatureBytes coEvery { deviceKeyManager.sign(any()) } returns signatureBytes
factory = DefaultDpopProofFactory( factory = DefaultDpopProofFactory(
@ -133,7 +133,7 @@ class DefaultDpopProofFactoryTest {
@Test @Test
fun `create returns None when device key unavailable`() = runTest { 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) val result = factory.create("POST", "https://example.com", null)

View file

@ -107,7 +107,7 @@ class DefaultDeviceRegistrarTest {
@Test @Test
fun `register returns DeviceKeyUnavailable when keystore has no key`() = runTest { fun `register returns DeviceKeyUnavailable when keystore has no key`() = runTest {
coEvery { deviceKeyManager.getPublicKey() } returns None coEvery { deviceKeyManager.getPublicKeyEncoded() } returns None
val result = registrar.register() val result = registrar.register()
@ -119,7 +119,7 @@ class DefaultDeviceRegistrarTest {
@Test @Test
fun `register surfaces nonce-endpoint API error`() = runTest { 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") @Suppress("UNCHECKED_CAST")
coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Error( coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Error(
cause = ApiResponseError.HttpException( cause = ApiResponseError.HttpException(
@ -138,7 +138,7 @@ class DefaultDeviceRegistrarTest {
@Test @Test
fun `register returns NonceDecryptionFailed when decryptor throws`() = runTest { 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( coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success(
data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"), data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"),
) )
@ -152,12 +152,12 @@ class DefaultDeviceRegistrarTest {
@Test @Test
fun `register returns SigningFailed when signing throws`() = runTest { 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( coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success(
data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"), data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"),
) )
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted" 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() val result = registrar.register()
@ -167,12 +167,12 @@ class DefaultDeviceRegistrarTest {
@Test @Test
fun `register surfaces register-endpoint API error and does not touch tokens or flag`() = runTest { 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( coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success(
data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"), data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"),
) )
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted" coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64) coEvery { deviceKeyManager.signDer(any()) } returns ByteArray(64)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
coEvery { authApi.registerDevice(any<RegisterApiRequest>()) } returns ApiResponse.Error( coEvery { authApi.registerDevice(any<RegisterApiRequest>()) } returns ApiResponse.Error(
cause = ApiResponseError.HttpException( cause = ApiResponseError.HttpException(
@ -191,12 +191,12 @@ class DefaultDeviceRegistrarTest {
@Test @Test
fun `register treats 409 Conflict as success, sets flag without persisting tokens`() = runTest { 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( coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success(
data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"), data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"),
) )
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted" coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64) coEvery { deviceKeyManager.signDer(any()) } returns ByteArray(64)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
coEvery { authApi.registerDevice(any<RegisterApiRequest>()) } returns ApiResponse.Error( coEvery { authApi.registerDevice(any<RegisterApiRequest>()) } returns ApiResponse.Error(
cause = ApiResponseError.HttpException( cause = ApiResponseError.HttpException(
@ -227,12 +227,12 @@ class DefaultDeviceRegistrarTest {
} }
private fun stubHappyPath() { private fun stubHappyPath() {
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65))
coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success( coEvery { authApi.requestDeviceNonce(any()) } returns ApiResponse.Success(
data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"), data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"),
) )
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted" coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64) coEvery { deviceKeyManager.signDer(any()) } returns ByteArray(64)
coEvery { authApi.registerDevice(any<RegisterApiRequest>()) } returns ApiResponse.Success( coEvery { authApi.registerDevice(any<RegisterApiRequest>()) } returns ApiResponse.Success(
data = TokenApiResponse( data = TokenApiResponse(
accessToken = "fresh-access", accessToken = "fresh-access",

View file

@ -166,12 +166,12 @@ class DefaultSessionTokenRefresherTest {
fun `refresh clears store when authenticate returns 403`() = runTest { fun `refresh clears store when authenticate returns 403`() = runTest {
coEvery { store.get() } returns None 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( coEvery { authApi.requestAuthNonce(any()) } returns ApiResponse.Success(
data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"), data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"),
) )
coEvery { nonceDecryptor.decryptNonce("abc") } returns "nonce-decrypted" coEvery { nonceDecryptor.decryptNonce("abc") } returns "nonce-decrypted"
coEvery { deviceKeyManager.sign(any()) } returns ByteArray(64) coEvery { deviceKeyManager.signDer(any()) } returns ByteArray(64)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
coEvery { authApi.authenticate(any<AuthApiRequest>()) } returns ApiResponse.Error( coEvery { authApi.authenticate(any<AuthApiRequest>()) } returns ApiResponse.Error(
cause = ApiResponseError.HttpException( cause = ApiResponseError.HttpException(
@ -191,7 +191,7 @@ class DefaultSessionTokenRefresherTest {
@Test @Test
fun `refresh returns DeviceKeyUnavailable when authenticate fallback has no key`() = runTest { fun `refresh returns DeviceKeyUnavailable when authenticate fallback has no key`() = runTest {
coEvery { store.get() } returns None coEvery { store.get() } returns None
coEvery { deviceKeyManager.getPublicKey() } returns None coEvery { deviceKeyManager.getPublicKeyEncoded() } returns None
val result = refresher.refresh() val result = refresher.refresh()
@ -343,12 +343,12 @@ class DefaultSessionTokenRefresherTest {
} }
private fun stubAuthenticateHappyPath() { private fun stubAuthenticateHappyPath() {
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65))
coEvery { authApi.requestAuthNonce(any()) } returns ApiResponse.Success( coEvery { authApi.requestAuthNonce(any()) } returns ApiResponse.Success(
data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"), data = NonceApiResponse(cipheredNonce = "abc", expiresAt = "2024-01-01T00:00:00Z"),
) )
coEvery { nonceDecryptor.decryptNonce("abc") } returns "nonce-decrypted" 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<AuthApiRequest>()) } returns ApiResponse.Success( coEvery { authApi.authenticate(any<AuthApiRequest>()) } returns ApiResponse.Success(
data = TokenApiResponse( data = TokenApiResponse(
accessToken = "post-auth-access", accessToken = "post-auth-access",

View file

@ -167,7 +167,7 @@ class DefaultWalletRegistrarTest {
@Test @Test
fun `register returns DeviceKeyUnavailable when keystore has no key`() = runTest { 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) val result = registrar.register(WALLET_ID, mobileSigner)
@ -178,7 +178,7 @@ class DefaultWalletRegistrarTest {
@Test @Test
fun `register surfaces nonce-endpoint API error`() = runTest { 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") @Suppress("UNCHECKED_CAST")
coEvery { authApi.requestWalletNonce(any()) } returns ApiResponse.Error( coEvery { authApi.requestWalletNonce(any()) } returns ApiResponse.Error(
cause = ApiResponseError.HttpException( cause = ApiResponseError.HttpException(
@ -197,7 +197,7 @@ class DefaultWalletRegistrarTest {
@Test @Test
fun `register returns NonceDecryptionFailed when decryptor throws`() = runTest { 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 { authApi.requestWalletNonce(any()) } returns nonceSuccess()
coEvery { nonceDecryptor.decryptNonce("abc") } throws IllegalStateException("OAEP failed") coEvery { nonceDecryptor.decryptNonce("abc") } throws IllegalStateException("OAEP failed")
@ -209,7 +209,7 @@ class DefaultWalletRegistrarTest {
@Test @Test
fun `register returns SigningFailed when signer throws (e g cancelled NFC or biometric)`() = runTest { 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 { authApi.requestWalletNonce(any()) } returns nonceSuccess()
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted" coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
val failingSigner = WalletSigner { throw IllegalStateException("user cancelled") } val failingSigner = WalletSigner { throw IllegalStateException("user cancelled") }
@ -272,7 +272,7 @@ class DefaultWalletRegistrarTest {
} }
private fun stubHappyPath() { private fun stubHappyPath() {
coEvery { deviceKeyManager.getPublicKey() } returns Some(ByteArray(65)) coEvery { deviceKeyManager.getPublicKeyEncoded() } returns Some(ByteArray(65))
coEvery { authApi.requestWalletNonce(any()) } returns nonceSuccess() coEvery { authApi.requestWalletNonce(any()) } returns nonceSuccess()
coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted" coEvery { nonceDecryptor.decryptNonce("abc") } returns "decrypted"
} }