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.
|
* 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>
|
||||||
}
|
}
|
||||||
|
|
@ -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?,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
@ -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}"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue