diff --git a/CHANGELOG.md b/CHANGELOG.md index 73a8c032..c07cdba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- Wallet instance lifecycle (SID-AUTH-06, go-wallet-backend#319): + `SirosWallet.listWalletInstances()`, `setWalletInstanceStatus()` and + `deactivateWallet()` (revokes every instance server-side, then forgets the + local account), backed by the new `WalletInstance` type and + `BackendApiClient` methods. WIA generation now sends the logged-in passkey's + `credential_id` so the backend can link the instance to the passkey. + `AuthException.errorCode` now carries the AS's error code, so a 403 + `WALLET_SUSPENDED` / `WALLET_REVOKED` login refusal is distinguishable + from a plain authentication failure. - Answer the engine's `sign_client_auth` sign request (go-wallet-backend#317, wallet-frontend#282) on both the legacy engine transport and WMP: sign the RFC 9449 DPoP proof and a fresh OAuth client attestation PoP for each diff --git a/sdk/auth/src/main/kotlin/org/siros/sdk/auth/AuthServerClient.kt b/sdk/auth/src/main/kotlin/org/siros/sdk/auth/AuthServerClient.kt index e01ee0a8..1611614a 100644 --- a/sdk/auth/src/main/kotlin/org/siros/sdk/auth/AuthServerClient.kt +++ b/sdk/auth/src/main/kotlin/org/siros/sdk/auth/AuthServerClient.kt @@ -282,7 +282,13 @@ class AuthServerClient( if (!response.isSuccessful) { Timber.e("AS request failed: ${response.code} — $url") - throw AuthException("AS request failed: ${response.code} — $path", code = response.code) + // Carry the AS's stable error code (e.g. WALLET_SUSPENDED / + // WALLET_REVOKED from a SID-AUTH-06 login refusal) so callers + // can tell a lifecycle refusal from a plain 401/403. + val errorCode = runCatching { + (json.parseToJsonElement(responseBody).jsonObject["error"] as? kotlinx.serialization.json.JsonPrimitive)?.content + }.getOrNull()?.takeIf { it.isNotBlank() } ?: "auth_failed" + throw AuthException("AS request failed: ${response.code} — $path", errorCode = errorCode, code = response.code) } Timber.d("AS response: ${response.code} — $path") diff --git a/sdk/auth/src/main/kotlin/org/siros/sdk/auth/BackendApiClient.kt b/sdk/auth/src/main/kotlin/org/siros/sdk/auth/BackendApiClient.kt index cdc3add6..5befe367 100644 --- a/sdk/auth/src/main/kotlin/org/siros/sdk/auth/BackendApiClient.kt +++ b/sdk/auth/src/main/kotlin/org/siros/sdk/auth/BackendApiClient.kt @@ -19,6 +19,9 @@ import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import timber.log.Timber + +private val JSON_MEDIA_TYPE = "application/json".toMediaType() + /** * Authenticated HTTP client for the wallet backend REST API. * @@ -129,7 +132,7 @@ class BackendApiClient( } val builder = Request.Builder() .url("$baseUrl/user/session/refresh") - .post(body.toString().toRequestBody("application/json".toMediaType())) + .post(body.toString().toRequestBody(JSON_MEDIA_TYPE)) addCommonHeaders(builder) execute(builder.build()) } @@ -212,6 +215,11 @@ class BackendApiClient( * "the sub claim MUST specify client_id value of the OAuth Client"; * omitting this falls back to the instance identifier (jkt) server-side. * @param nativeAttestation optional platform attestation evidence + * @param credentialId base64url WebAuthn credential id of the passkey this + * installation logs in with. The backend records it on the wallet + * instance so that suspending or revoking the instance also refuses + * login with that passkey (SID-AUTH-06, go-wallet-backend#319). Optional; + * older backends ignore it. * @return WIA JWT string */ suspend fun generateWIA( @@ -219,6 +227,7 @@ class BackendApiClient( challenge: String, clientId: String? = null, nativeAttestation: JsonObject? = null, + credentialId: String? = null, ): String { val body = kotlinx.serialization.json.buildJsonObject { put("pop", kotlinx.serialization.json.JsonPrimitive(pop)) @@ -229,6 +238,9 @@ class BackendApiClient( if (nativeAttestation != null) { put("native_attestation", nativeAttestation) } + if (!credentialId.isNullOrBlank()) { + put("credential_id", kotlinx.serialization.json.JsonPrimitive(credentialId)) + } } val result = post("/wallet-provider/wia/generate", body) return result["wallet_instance_attestation"]?.let { @@ -236,6 +248,55 @@ class BackendApiClient( } ?: throw BackendApiException(0, "Missing wallet_instance_attestation in response", "") } + // ---- Wallet instance lifecycle (SID-AUTH-06, go-wallet-backend#319) ---- + + /** GET /user/session/instances — this user's wallet instances in the current tenant. */ + suspend fun listWalletInstances(): List { + val result = get("/user/session/instances") + // The backend always sends the array (empty when the user has no + // instances); its absence is a malformed response, not "no instances". + val arr = result["instances"] as? kotlinx.serialization.json.JsonArray + ?: throw BackendApiException(0, "Missing instances in response", "") + return arr.map { json.decodeFromJsonElement(WalletInstance.serializer(), it) } + } + + /** + * PUT /user/session/instances/{id}/status — suspend, reactivate or revoke + * one of this user's instances. Throws [BackendApiException] with code 404 + * for an instance that is not the caller's and 409 for an invalid + * transition (e.g. reactivating a revoked instance). + */ + suspend fun setWalletInstanceStatus(instanceId: String, status: String, reason: String? = null): WalletInstance { + val body = kotlinx.serialization.json.buildJsonObject { + put("status", kotlinx.serialization.json.JsonPrimitive(status)) + if (!reason.isNullOrBlank()) put("reason", kotlinx.serialization.json.JsonPrimitive(reason)) + } + val result = put("/user/session/instances/$instanceId/status", body) + // Today the backend answers {id, status}; decode the whole object when + // it sends more, so callers see every field it returns. + return runCatching { json.decodeFromJsonElement(WalletInstance.serializer(), result) }.getOrElse { + WalletInstance( + id = (result["id"] as? kotlinx.serialization.json.JsonPrimitive)?.content ?: instanceId, + status = (result["status"] as? kotlinx.serialization.json.JsonPrimitive)?.content + ?: throw BackendApiException(0, "Missing status in response", ""), + ) + } + } + + /** + * POST /user/session/instances/revoke-all — deactivate the wallet: every + * instance revoked, wallet data erased server-side, new enrollment required. + * @return how many instances were revoked by this call + */ + suspend fun revokeAllWalletInstances(reason: String? = null): Int { + val body = kotlinx.serialization.json.buildJsonObject { + if (!reason.isNullOrBlank()) put("reason", kotlinx.serialization.json.JsonPrimitive(reason)) + } + val result = post("/user/session/instances/revoke-all", body) + return (result["revoked"] as? kotlinx.serialization.json.JsonPrimitive)?.content?.toIntOrNull() + ?: throw BackendApiException(0, "Missing revoked count in response", "") + } + /** * POST /wallet-provider/fido2-attestation/register — register a FIDO2/CTAP2 * hardware-key attestation once, at key-creation time, so the backend can @@ -290,7 +351,15 @@ class BackendApiClient( private suspend fun post(path: String, body: JsonObject): JsonObject = withContext(Dispatchers.IO) { val builder = Request.Builder() .url("$baseUrl$path") - .post(body.toString().toRequestBody("application/json".toMediaType())) + .post(body.toString().toRequestBody(JSON_MEDIA_TYPE)) + addCommonHeaders(builder) + execute(builder.build()) + } + + private suspend fun put(path: String, body: JsonObject): JsonObject = withContext(Dispatchers.IO) { + val builder = Request.Builder() + .url("$baseUrl$path") + .put(body.toString().toRequestBody(JSON_MEDIA_TYPE)) addCommonHeaders(builder) execute(builder.build()) } diff --git a/sdk/auth/src/main/kotlin/org/siros/sdk/auth/WalletInstance.kt b/sdk/auth/src/main/kotlin/org/siros/sdk/auth/WalletInstance.kt new file mode 100644 index 00000000..9e716307 --- /dev/null +++ b/sdk/auth/src/main/kotlin/org/siros/sdk/auth/WalletInstance.kt @@ -0,0 +1,38 @@ +package org.siros.sdk.auth + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * One wallet installation as the backend sees it (go-wallet-backend + * `domain.WalletInstance`): registered when the installation first obtains a + * Wallet Instance Attestation and identified by the JWK thumbprint of its + * instance key. Lifecycle (SID-AUTH-06): `active` → `suspended` is reversible + * and only blocks; `revoked` is terminal; revoking the last non-revoked + * instance deactivates the wallet and erases its data server-side. + */ +@Serializable +data class WalletInstance( + val id: String, + @SerialName("tenant_id") val tenantId: String = "", + @SerialName("user_id") val userId: String? = null, + val status: String, + @SerialName("wscd_type") val wscdType: String = "", + /** + * base64url WebAuthn credential id of the passkey this instance logs in + * with, when the client reported it at WIA generation. Lets an app match + * an instance to a passkey; absent for instances attested by older SDKs. + */ + @SerialName("credential_id") val credentialId: String? = null, + @SerialName("attestation_source") val attestationSource: String = "", + @SerialName("last_attested_at") val lastAttestedAt: String? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, + @SerialName("status_reason") val statusReason: String? = null, +) { + companion object { + const val STATUS_ACTIVE = "active" + const val STATUS_SUSPENDED = "suspended" + const val STATUS_REVOKED = "revoked" + } +} diff --git a/sdk/auth/src/test/kotlin/org/siros/sdk/auth/AuthServerClientTest.kt b/sdk/auth/src/test/kotlin/org/siros/sdk/auth/AuthServerClientTest.kt index 34a7a760..8f7e5a43 100644 --- a/sdk/auth/src/test/kotlin/org/siros/sdk/auth/AuthServerClientTest.kt +++ b/sdk/auth/src/test/kotlin/org/siros/sdk/auth/AuthServerClientTest.kt @@ -8,6 +8,7 @@ import okhttp3.mockwebserver.MockWebServer import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.fail import org.junit.Before import org.junit.Test import org.siros.sdk.credentials.AuthException @@ -190,4 +191,33 @@ class AuthServerClientTest { server.enqueue(MockResponse().setResponseCode(401).setBody("""{"error":"unauthorized"}""")) client.loginBegin() } + + /** + * SID-AUTH-06 (go-wallet-backend#319): a suspended or revoked wallet + * instance is a 403 with a stable code, not a retryable auth failure - + * the code must survive into the exception so the app can explain it. + */ + @Test + fun `login refusal carries the AS error code`(): Unit = runBlocking { + server.enqueue(MockResponse().setResponseCode(403).setBody("""{"error":"WALLET_REVOKED"}""")) + try { + client.loginFinish(challengeId = "c1", credential = buildJsonObject { }) + fail("expected AuthException") + } catch (e: AuthException) { + assertEquals(403, e.code) + assertEquals("WALLET_REVOKED", e.errorCode) + } + } + + @Test + fun `non-JSON failure body falls back to auth_failed`(): Unit = runBlocking { + server.enqueue(MockResponse().setResponseCode(502).setBody("bad gateway")) + try { + client.loginBegin() + fail("expected AuthException") + } catch (e: AuthException) { + assertEquals(502, e.code) + assertEquals("auth_failed", e.errorCode) + } + } } diff --git a/sdk/auth/src/test/kotlin/org/siros/sdk/auth/BackendApiClientTest.kt b/sdk/auth/src/test/kotlin/org/siros/sdk/auth/BackendApiClientTest.kt index d924f770..705e065a 100644 --- a/sdk/auth/src/test/kotlin/org/siros/sdk/auth/BackendApiClientTest.kt +++ b/sdk/auth/src/test/kotlin/org/siros/sdk/auth/BackendApiClientTest.kt @@ -17,6 +17,7 @@ import okhttp3.mockwebserver.MockWebServer import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Assert.fail import org.junit.Before import org.junit.Test import java.util.Base64 @@ -339,6 +340,116 @@ class BackendApiClientTest { return AccessToken("$header.$body.$signature") } + // ---- wallet instance lifecycle (SID-AUTH-06, go-wallet-backend#319) ---- + + @Test + fun generate_wia_sends_credential_id_when_given_and_omits_it_otherwise() = runBlocking { + server.enqueue(MockResponse().setBody("""{"wallet_instance_attestation":"wia.jwt"}""")) + server.enqueue(MockResponse().setBody("""{"wallet_instance_attestation":"wia.jwt"}""")) + val client = newClient() + client.setAppToken("t") + + client.generateWIA(pop = "pop.jwt", challenge = "c-1", clientId = "siros://cb", credentialId = "pk-1") + val withId = server.takeRequest().body.readUtf8() + assertTrue(withId, withId.contains("\"credential_id\":\"pk-1\"")) + + client.generateWIA(pop = "pop.jwt", challenge = "c-2") + val without = server.takeRequest().body.readUtf8() + assertTrue(without, !without.contains("credential_id")) + } + + @Test + fun list_wallet_instances_decodes_backend_shape() = runBlocking { + server.enqueue( + MockResponse().setBody( + """{"instances":[{"id":"jkt-1","tenant_id":"default","user_id":"u1","status":"suspended", + "wscd_type":"native_android","credential_id":"pk-1","attestation_source":"play_integrity", + "last_attested_at":"2026-09-08T10:00:00Z","status_reason":"lost phone","unknown_member":1}]}""" + ) + ) + val client = newClient() + client.setAppToken("t") + + val instances = client.listWalletInstances() + + assertEquals("/user/session/instances", server.takeRequest().path) + assertEquals(1, instances.size) + assertEquals("jkt-1", instances[0].id) + assertEquals(WalletInstance.STATUS_SUSPENDED, instances[0].status) + assertEquals("pk-1", instances[0].credentialId) + assertEquals("lost phone", instances[0].statusReason) + } + + @Test + fun list_wallet_instances_fails_on_malformed_response() = runBlocking { + server.enqueue(MockResponse().setBody("""{"unexpected":true}""")) + val client = newClient() + client.setAppToken("t") + try { + client.listWalletInstances() + fail("a response without the instances array must not read as 'no instances'") + } catch (e: BackendApiException) { + assertTrue(e.message!!.contains("instances")) + } + } + + @Test + fun set_wallet_instance_status_decodes_full_object_when_returned() = runBlocking { + server.enqueue(MockResponse().setBody("""{"id":"jkt-1","status":"revoked","credential_id":"pk-1","status_reason":"stolen"}""")) + val client = newClient() + client.setAppToken("t") + + val result = client.setWalletInstanceStatus("jkt-1", WalletInstance.STATUS_REVOKED, "stolen") + + assertEquals("revoked", result.status) + assertEquals("pk-1", result.credentialId) + assertEquals("stolen", result.statusReason) + } + + @Test + fun set_wallet_instance_status_puts_status_and_reason() = runBlocking { + server.enqueue(MockResponse().setBody("""{"id":"jkt-1","status":"suspended"}""")) + val client = newClient() + client.setAppToken("t") + + val result = client.setWalletInstanceStatus("jkt-1", WalletInstance.STATUS_SUSPENDED, reason = "lost phone") + + val request = server.takeRequest() + assertEquals("PUT", request.method) + assertEquals("/user/session/instances/jkt-1/status", request.path) + val body = request.body.readUtf8() + assertTrue(body, body.contains("\"status\":\"suspended\"") && body.contains("\"reason\":\"lost phone\"")) + assertEquals("suspended", result.status) + } + + @Test + fun revoke_all_wallet_instances_fails_on_malformed_response() = runBlocking { + server.enqueue(MockResponse().setBody("""{"ok":true}""")) + val client = newClient() + client.setAppToken("t") + try { + client.revokeAllWalletInstances() + fail("a reply without the revoked count must not read as zero revoked") + } catch (e: BackendApiException) { + assertTrue(e.message!!.contains("revoked")) + } + } + + @Test + fun revoke_all_wallet_instances_posts_and_returns_count() = runBlocking { + server.enqueue(MockResponse().setBody("""{"revoked":2}""")) + val client = newClient() + client.setAppToken("t") + + val revoked = client.revokeAllWalletInstances("device stolen") + + val request = server.takeRequest() + assertEquals("POST", request.method) + assertEquals("/user/session/instances/revoke-all", request.path) + assertTrue(request.body.readUtf8().contains("device stolen")) + assertEquals(2, revoked) + } + private fun newClient(): BackendApiClient { val baseUrl = server.url("/").toString().trimEnd('/') return BackendApiClient(baseUrl = baseUrl, tenantId = "default") diff --git a/sdk/wallet/src/main/kotlin/org/siros/sdk/wallet/SirosWallet.kt b/sdk/wallet/src/main/kotlin/org/siros/sdk/wallet/SirosWallet.kt index 1ca669db..d9b2bcc9 100644 --- a/sdk/wallet/src/main/kotlin/org/siros/sdk/wallet/SirosWallet.kt +++ b/sdk/wallet/src/main/kotlin/org/siros/sdk/wallet/SirosWallet.kt @@ -36,6 +36,7 @@ import org.siros.sdk.auth.AuthProvider import org.siros.sdk.auth.CredentialManagerAuthProvider import org.siros.sdk.auth.LocalAuthProvider import org.siros.sdk.auth.PrfOutput +import org.siros.sdk.auth.WalletInstance import org.siros.sdk.auth.WebAuthnAuthClient import org.siros.sdk.auth.WscdAutoEnrollHint import org.siros.sdk.credentials.AuthException @@ -141,6 +142,9 @@ private data class PendingAuthorization( val state: String, ) +/** Facade methods that need a live backend session throw this when there is none. */ +private const val NOT_LOGGED_IN = "Not logged in" + /** * A randomly-generated uint32-range identifier, matching wallet-frontend's * `WalletStateUtils.getRandomUint32()` exactly (CSPRNG, full 1..2^32-1 range, @@ -254,6 +258,49 @@ class SirosWallet private constructor( accountRegistry.upsertAccount(updated) } + // ── Wallet instance lifecycle (SID-AUTH-06) ───────────────────── + + /** + * This user's wallet instances in the current tenant, from the backend + * (`GET /user/session/instances`). One instance per installation; match an + * instance to a passkey via [WalletInstance.credentialId] when present. + * Requires a backend with go-wallet-backend#319; older backends answer 404, + * surfaced as [org.siros.sdk.auth.BackendApiException]. + */ + suspend fun listWalletInstances(): List { + val client = apiClient ?: throw AuthException(NOT_LOGGED_IN) + return client.listWalletInstances() + } + + /** + * Suspend, reactivate or revoke one of this user's wallet instances + * (`PUT /user/session/instances/{id}/status`). Suspension is reversible and + * only blocks that installation (login, attestation, sessions); revocation + * is terminal. Revoking the last non-revoked instance deactivates the + * wallet - prefer [deactivateWallet] for that, which also clears local state. + */ + suspend fun setWalletInstanceStatus(instanceId: String, status: String, reason: String? = null): WalletInstance { + val client = apiClient ?: throw AuthException(NOT_LOGGED_IN) + return client.setWalletInstanceStatus(instanceId, status, reason) + } + + /** + * Deactivate this wallet: revoke every wallet instance of the user + * (`POST /user/session/instances/revoke-all`). The backend erases the + * wallet's private data and server-side credentials, and refuses every + * passkey of the user at login with `WALLET_REVOKED`; a new enrollment is + * required afterwards. The local cached account is forgotten and the + * wallet logged out, since the vault it decrypts no longer exists. + * @return how many instances the backend revoked + */ + suspend fun deactivateWallet(reason: String? = null): Int { + val client = apiClient ?: throw AuthException(NOT_LOGGED_IN) + val revoked = client.revokeAllWalletInstances(reason) + Timber.i("Wallet deactivated: $revoked instance(s) revoked; forgetting local account") + deleteAccount() + return revoked + } + /** Set a listener for events that require user interaction (credential picker, etc.). */ fun setEventListener(listener: WalletEventListener) { eventListener = listener @@ -1486,6 +1533,10 @@ class SirosWallet private constructor( // that flagged sub= as a FAIL. clientId = clientAttestationClientId(), nativeAttestation = nativeAttestation, + // Links this instance to the passkey it logs in with, so + // suspending or revoking the instance also refuses login + // with that passkey (SID-AUTH-06, go-wallet-backend#319). + credentialId = sessionStore.credentialId?.takeIf { it.isNotBlank() }, ) cachedWia = wia cachedWiaExpiresAt = CredentialUtils.parseJwtPayload(wia) diff --git a/sdk/wallet/src/test/kotlin/org/siros/sdk/wallet/SirosWalletTest.kt b/sdk/wallet/src/test/kotlin/org/siros/sdk/wallet/SirosWalletTest.kt index b682bdcc..316d260d 100644 --- a/sdk/wallet/src/test/kotlin/org/siros/sdk/wallet/SirosWalletTest.kt +++ b/sdk/wallet/src/test/kotlin/org/siros/sdk/wallet/SirosWalletTest.kt @@ -1530,6 +1530,34 @@ class SirosWalletTest { } } + /** + * `deactivateWallet()` (SID-AUTH-06): revoke every instance server-side, + * then forget the local account - the vault it decrypts no longer exists. + */ + @Test + fun deactivateWallet_revokesAllAndForgetsLocalAccount() = runTest(dispatcher) { + val apiClient = mockk(relaxed = true) + coEvery { apiClient.revokeAllWalletInstances("device stolen") } returns 2 + val accountRegistry = mockk(relaxed = true) + every { accountRegistry.activeAccountId } returns "acct-1" + every { accountRegistry.listLoginableAccounts() } returns emptyList() + val wallet = newWallet( + "_state" to MutableStateFlow(WalletState.Ready(userId = "user-1", displayName = "Alice")), + "engineSession" to mockk(relaxed = true), + "keystore" to mockk(relaxed = true), + "sessionStore" to mockk(relaxed = true), + "accountRegistry" to accountRegistry, + "scope" to CoroutineScope(dispatcher + SupervisorJob()), + "apiClient" to apiClient, + ) + + val revoked = wallet.deactivateWallet("device stolen") + + assertEquals(2, revoked) + coVerify(exactly = 1) { apiClient.revokeAllWalletInstances("device stolen") } + verify(exactly = 1) { accountRegistry.removeAccount("acct-1") } + } + /** * When requesting a backend Key Attestation, each freshly generated * credential-issuance key's own FIDO2/CTAP2 attestation (not the wallet's