Skip to content

Commit 91fa4e4

Browse files
committed
Add device sessions tracking, IP location lookup, and device info.
Track active client device sessions with location resolution, device models, and platform information. Update `Session` and `RemoteWalletClient` models by replacing `clientDetails` with `clientDevice` (e.g. device model) and `clientPlatform` (e.g. OS version or browser name and version). Update `WalletBackend` interface and `WalletBackendBase` with overridable `lookupLocationFromIpAddress()` suspending function. Add `IpLocationLookup` interface and `IpWhoIsLocationLookup` implementation in backend utilizing ipwhois.io with 1-hour in-memory cache and automatic resolution of loopback and local network IP addresses without external network requests. Update Android, iOS, and Web clients to collect and transmit `clientDevice` and `clientPlatform` during `markAlive()` and client creation flows, and display device sessions formatted with device model, platform version, resolved location, and last-seen timestamps. Update `privacy.md` documentation to describe backend collection of IP address, client device model, and platform version for device session management. Test: Unit tests in `IpWhoIsLocationLookupTest` and `WalletClientTest`. Test: Executed `./gradlew :shared:allTests :backend:test :webApp:assemble :androidApp:assembleDebug` and verified all test suites pass cleanly. Test: Executed `xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -sdk iphonesimulator build` and verified clean iOS compilation. Signed-off-by: David Zeuthen <zeuthen@gmail.com>
1 parent 6ab22c1 commit 91fa4e4

17 files changed

Lines changed: 621 additions & 26 deletions

File tree

androidApp/src/main/java/org/multipaz/wallet/android/App.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,8 @@ class App private constructor() {
242242
secureArea = secureArea,
243243
httpClientEngineFactory = Android,
244244
numReaderKeys = 10,
245+
clientDevice = getAndroidClientDevice(),
246+
clientPlatform = getAndroidClientPlatform()
245247
)
246248

247249
userIssuerTrustManager = TrustManager(
@@ -572,3 +574,20 @@ class App private constructor() {
572574
}
573575
}
574576
}
577+
578+
private fun getAndroidClientDevice(): String {
579+
val manufacturer = android.os.Build.MANUFACTURER
580+
val model = android.os.Build.MODEL
581+
return if (model.lowercase().startsWith(manufacturer.lowercase())) {
582+
model
583+
} else {
584+
val formattedManufacturer = manufacturer.replaceFirstChar {
585+
if (it.isLowerCase()) it.titlecase(java.util.Locale.getDefault()) else it.toString()
586+
}
587+
"$formattedManufacturer $model"
588+
}
589+
}
590+
591+
private fun getAndroidClientPlatform(): String {
592+
return "Android ${android.os.Build.VERSION.RELEASE}"
593+
}

androidApp/src/main/java/org/multipaz/wallet/android/ui/settings/DeviceSessionsScreen.kt

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -185,22 +185,32 @@ fun DeviceSessionsScreen(
185185
.thenByDescending { it.lastSeenMillis }
186186
)
187187
for (session in sortedSessions) {
188-
val deviceName = when (session.clientType) {
188+
val fallbackDeviceName = when (session.clientType) {
189189
ClientType.WEB -> stringResource(R.string.device_sessions_screen_device_web)
190190
ClientType.ANDROID -> stringResource(R.string.device_sessions_screen_device_android)
191191
ClientType.IOS -> stringResource(R.string.device_sessions_screen_device_ios)
192192
}
193+
val deviceName = session.clientDevice ?: fallbackDeviceName
193194
val deviceIcon = when (session.clientType) {
194195
ClientType.WEB -> Icons.Outlined.Computer
195196
ClientType.ANDROID, ClientType.IOS -> Icons.Outlined.Smartphone
196197
}
197198
val isCurrentDevice = (currentClientId.value != null && session.clientId == currentClientId.value)
198199
val lastSeenText = durationFromNowText(Instant.fromEpochMilliseconds(session.lastSeenMillis))
199-
val secondaryText = if (isCurrentDevice) {
200-
"${stringResource(R.string.device_sessions_screen_this_device)}$lastSeenText"
201-
} else {
202-
lastSeenText
200+
val secondaryTextParts = mutableListOf<String>()
201+
if (isCurrentDevice) {
202+
secondaryTextParts.add(stringResource(R.string.device_sessions_screen_this_device))
203203
}
204+
if (!session.clientPlatform.isNullOrBlank()) {
205+
secondaryTextParts.add(session.clientPlatform!!)
206+
}
207+
if (!session.location.isNullOrBlank()) {
208+
secondaryTextParts.add(session.location!!)
209+
}
210+
if (!isCurrentDevice) {
211+
secondaryTextParts.add(lastSeenText)
212+
}
213+
val secondaryText = secondaryTextParts.joinToString("")
204214

205215
FloatingItemText(
206216
text = deviceName,
@@ -241,11 +251,14 @@ fun DeviceSessionsScreen(
241251
}
242252

243253
sessionToSignOut.value?.let { targetSession ->
244-
val targetName = when (targetSession.clientType) {
254+
val baseTargetName = when (targetSession.clientType) {
245255
ClientType.WEB -> stringResource(R.string.device_sessions_screen_device_web)
246256
ClientType.ANDROID -> stringResource(R.string.device_sessions_screen_device_android)
247257
ClientType.IOS -> stringResource(R.string.device_sessions_screen_device_ios)
248258
}
259+
val targetName = targetSession.clientDevice
260+
?: targetSession.clientPlatform
261+
?: baseTargetName
249262
AlertDialog(
250263
onDismissRequest = { sessionToSignOut.value = null },
251264
title = {

backend/build.gradle.kts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ dependencies {
3636
implementation(libs.ktor.server.logging)
3737
implementation(libs.logback.classic)
3838
implementation(libs.identity.google.api.client)
39+
40+
testImplementation(libs.kotlin.test)
41+
testImplementation(libs.kotlinx.coroutines.test)
42+
testImplementation(libs.ktor.client.mock)
3943
}
4044

4145
tasks.named<ProcessResources>("processResources") {
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package org.multipaz.wallet.backend
2+
3+
/**
4+
* Interface for looking up human-readable location strings from IP addresses.
5+
*/
6+
interface IpLocationLookup {
7+
/**
8+
* Looks up a human-readable location string for the given IP address.
9+
*
10+
* @param ipAddress the IP address to look up, or `null`.
11+
* @return a location string (e.g. "Mountain View, United States" or "Local Network"), or `null` if unresolvable.
12+
*/
13+
suspend fun lookup(ipAddress: String?): String?
14+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package org.multipaz.wallet.backend
2+
3+
import io.ktor.client.HttpClient
4+
import io.ktor.client.engine.HttpClientEngine
5+
import io.ktor.client.engine.java.Java
6+
import io.ktor.client.request.get
7+
import io.ktor.client.statement.bodyAsText
8+
import kotlinx.coroutines.sync.Mutex
9+
import kotlinx.coroutines.sync.withLock
10+
import kotlinx.serialization.json.Json
11+
import kotlinx.serialization.json.booleanOrNull
12+
import kotlinx.serialization.json.jsonObject
13+
import kotlinx.serialization.json.jsonPrimitive
14+
import org.multipaz.util.Logger
15+
import java.net.InetAddress
16+
import java.util.concurrent.ConcurrentHashMap
17+
import kotlin.time.Clock
18+
import kotlin.time.Duration.Companion.hours
19+
20+
/**
21+
* Implementation of [IpLocationLookup] using the ipwhois.io API with in-memory caching.
22+
*
23+
* @param httpClientEngine optional [HttpClientEngine] for custom HTTP transport (e.g. testing with MockEngine).
24+
* @param clock the time source for TTL calculation.
25+
* @param cacheTtlMillis duration in milliseconds before a cached lookup expires (default 1 hour).
26+
*/
27+
class IpWhoIsLocationLookup(
28+
httpClientEngine: HttpClientEngine? = null,
29+
private val clock: Clock = Clock.System,
30+
private val cacheTtlMillis: Long = 1.hours.inWholeMilliseconds
31+
) : IpLocationLookup {
32+
33+
private val httpClient = if (httpClientEngine != null) {
34+
HttpClient(httpClientEngine)
35+
} else {
36+
HttpClient(Java)
37+
}
38+
39+
private data class CacheEntry(
40+
val location: String?,
41+
val timestampMillis: Long
42+
)
43+
44+
private val cache = ConcurrentHashMap<String, CacheEntry>()
45+
private val json = Json { ignoreUnknownKeys = true }
46+
private val mutex = Mutex()
47+
48+
override suspend fun lookup(ipAddress: String?): String? {
49+
if (ipAddress.isNullOrBlank()) {
50+
return null
51+
}
52+
val trimmedIp = ipAddress.trim()
53+
54+
if (isPrivateOrLocalIp(trimmedIp)) {
55+
return "Local Network"
56+
}
57+
58+
val now = clock.now().toEpochMilliseconds()
59+
val existingEntry = cache[trimmedIp]
60+
if (existingEntry != null && (now - existingEntry.timestampMillis) < cacheTtlMillis) {
61+
return existingEntry.location
62+
}
63+
64+
return mutex.withLock {
65+
val entryUnderLock = cache[trimmedIp]
66+
if (entryUnderLock != null && (now - entryUnderLock.timestampMillis) < cacheTtlMillis) {
67+
return@withLock entryUnderLock.location
68+
}
69+
70+
val resolvedLocation = fetchLocationFromApi(trimmedIp)
71+
cache[trimmedIp] = CacheEntry(
72+
location = resolvedLocation,
73+
timestampMillis = now
74+
)
75+
resolvedLocation
76+
}
77+
}
78+
79+
private fun isPrivateOrLocalIp(ip: String): Boolean {
80+
return try {
81+
val inet = InetAddress.getByName(ip)
82+
inet.isLoopbackAddress || inet.isSiteLocalAddress || inet.isLinkLocalAddress || inet.isAnyLocalAddress
83+
} catch (_: Exception) {
84+
false
85+
}
86+
}
87+
88+
private suspend fun fetchLocationFromApi(ip: String): String? {
89+
return try {
90+
val responseText = httpClient.get("https://ipwho.is/$ip").bodyAsText()
91+
val jsonObj = json.parseToJsonElement(responseText).jsonObject
92+
val success = jsonObj["success"]?.jsonPrimitive?.booleanOrNull ?: false
93+
if (!success) {
94+
return null
95+
}
96+
val city = jsonObj["city"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
97+
val country = jsonObj["country"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
98+
val region = jsonObj["region"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
99+
100+
when {
101+
city != null && country != null -> "$city, $country"
102+
region != null && country != null -> "$region, $country"
103+
country != null -> country
104+
else -> null
105+
}
106+
} catch (e: Exception) {
107+
Logger.w(TAG, "Failed to resolve IP location for $ip", e)
108+
null
109+
}
110+
}
111+
112+
companion object {
113+
private const val TAG = "IpWhoIsLocationLookup"
114+
}
115+
}

backend/src/main/java/org/multipaz/wallet/backend/WalletBackendImpl.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeToken
44
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier
55
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport
66
import com.google.api.client.json.gson.GsonFactory
7+
import io.ktor.server.plugins.origin
78
import org.multipaz.asn1.ASN1Integer
89
import org.multipaz.cbor.annotation.CborSerializable;
910
import org.multipaz.crypto.AsymmetricKey
@@ -15,6 +16,7 @@ import org.multipaz.rpc.backend.RpcAuthBackendDelegate
1516
import org.multipaz.rpc.handler.RpcAuthContext
1617
import org.multipaz.rpc.handler.RpcAuthInspector
1718
import org.multipaz.securearea.KeyAttestation
19+
import org.multipaz.server.common.KtorCall
1820
import org.multipaz.server.enrollment.ServerIdentity
1921
import org.multipaz.server.enrollment.getServerIdentity
2022
import org.multipaz.util.Logger
@@ -30,6 +32,8 @@ import kotlin.time.Duration.Companion.seconds
3032

3133
private const val TAG = "WalletBackendImpl"
3234

35+
private val ipLocationLookup: IpLocationLookup = IpWhoIsLocationLookup()
36+
3337
@RpcState(
3438
endpoint = "wallet_backend",
3539
creatable = true
@@ -114,6 +118,12 @@ class WalletBackendImpl: WalletBackendBase(), WalletBackend, RpcAuthInspector by
114118

115119
override suspend fun getClientId() = RpcAuthContext.getClientId()
116120

121+
override suspend fun getIpAddress(): String = KtorCall.getCall().request.origin.remoteAddress
122+
123+
override suspend fun lookupLocationFromIpAddress(ipAddress: String?): String? {
124+
return ipLocationLookup.lookup(ipAddress)
125+
}
126+
117127
override suspend fun certifyReaderKeys(readerKeys: List<KeyAttestation>): List<X509CertChain> {
118128
// TODO: if dealing with Android client, verify attestations
119129
val identity = getServerIdentity(ServerIdentity.READER_ROOT) as AsymmetricKey.X509CertifiedExplicit

backend/src/main/resources/docs/privacy.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ ${APP_NAME} is a secure, privacy-preserving digital identity credential wallet d
88
## 2. Information We Collect
99
${APP_NAME} is designed with strict data minimization principles. We do not track user identity attributes or share personal data with external third parties without user consent—either given explicitly prompt-by-prompt during document presentation flows or configured via saved pre-consent policies.
1010

11+
To help inform users where and when their account data was accessed, the backend collects the client's IP address (used to resolve coarse location), client device model, and platform name and version. This information is stored securely and displayed to the user in the Device Sessions management screen.
12+
1113
## 3. Secure Credential Storage & Encryption
1214
Credential data and personally identifiable information (PII) are stored securely in encrypted application storage, while private key material may be stored in the mobile device's hardware-backed Secure Area (such as Android KeyStore / StrongBox or iOS Secure Enclave).
1315

0 commit comments

Comments
 (0)