Updated on 2026-08-14
This commit is contained in:
parent
d2fd1c94b2
commit
e156fd3ce1
21 changed files with 178 additions and 83 deletions
|
|
@ -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<NonceApiResponse>
|
||||
|
||||
/**
|
||||
|
|
@ -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<TokenApiResponse>
|
||||
|
||||
/**
|
||||
|
|
@ -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<NonceApiResponse>
|
||||
|
||||
/**
|
||||
|
|
@ -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<TokenApiResponse>
|
||||
|
||||
/**
|
||||
|
|
@ -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<TokenApiResponse>
|
||||
|
||||
|
|
@ -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<NonceApiResponse>
|
||||
|
||||
/**
|
||||
|
|
@ -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<TokenApiResponse>
|
||||
}
|
||||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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/"
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue