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

@ -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<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.
* @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<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
*/
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(
recover = { e ->
TangemLogger.e("Failed to get device public key", e)
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) {
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}"
}

View file

@ -9,11 +9,19 @@ internal object DisabledDeviceKeyManager : DeviceKeyManager {
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 {
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",
)
}
}

View file

@ -27,15 +27,15 @@ internal class DefaultDpopProofFactory(
override suspend fun create(httpMethod: String, httpUri: String, accessToken: String?): Option<String> =
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)

View file

@ -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,

View file

@ -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.
*

View file

@ -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 {

View file

@ -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 {

View file

@ -41,7 +41,7 @@ internal class DefaultDeviceRegistrar(
override suspend fun register(): Either<DeviceRegistrationError, Unit> = 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))

View file

@ -131,7 +131,7 @@ internal class DefaultSessionTokenRefresher(
private suspend fun runAuthenticate(): Either<SessionRefreshError, SessionTokens> = 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))

View file

@ -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()

View file

@ -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<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 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<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
fun `sign throws DeviceKeySigningException when key not found`() = runTest {
every { keyStore.getKey(KEY_ALIAS, null) } returns null

View file

@ -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)

View file

@ -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<RegisterApiRequest>()) } 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<RegisterApiRequest>()) } 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<RegisterApiRequest>()) } returns ApiResponse.Success(
data = TokenApiResponse(
accessToken = "fresh-access",

View file

@ -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<AuthApiRequest>()) } 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<AuthApiRequest>()) } returns ApiResponse.Success(
data = TokenApiResponse(
accessToken = "post-auth-access",

View file

@ -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"
}