Skip to content

Commit

Permalink
Add reusable Presentment primitives and use in samples/testapp.
Browse files Browse the repository at this point in the history
This PR adds support in identity-appsupport for generic presentment
primitives, including

- `PresentmentModel`: A model used for presetment.
- `PresentmentMechanism`: An interface for abstracting various presentment
  mechanisms with concrete implementations for ISO/IEC 18013-5:2021
  proximity presentations and W3C Digital Credentials on Android via
  Android Credential Manager. Support for other mechanisms including
  OpenID4VP via URI schemes will be added in the future.
- `PresementSource`: An interface for the application to provide data
  and policy for the presentment flow implemented by `PresentmentModel`
- `Presentment`: A composable which implements a fully-featured UI/UX
  suitable for use in end-user apps.

Also use this in samples/testapp

- Use this for QR engagement by using `Presentment` composable in
  `PresentmentScreen`.
- Add support for NFC engagement as an mdoc on Android using the new
  `Presentment` composable from dedicated `NfcEngagementActivity`
  activity which can also run on the lock screen.
- Add support for W3C Digital Credentials via Android Credential Manager
  using the new `Presentment` composable from a new dedicated activity
  `CredmanPresentmentActivity`.
- Add `TestAppSettingsModel` (currently ephemeral but planning to persist
  using new `Storage` interface in the future), use it, and expose toggles
  in various places in samples/testapp.

Note: presentments using NFC, QR, and Credman may run concurrently and
do not interfere with each other because they are separate activities
and use dedicated `PresentmentModel` instances. This is intentional
and desirable.

Other changes

- Rename "well-known-request" to "canned request" in `DocumentType` and
  use this in types so `MdocRequest` and `VcRequest` becomes
  `MdocCannedRequest` and `VcCannedRequest`.
- Move `ConsentField` machinery into dedicated `com.android.identity.request`
  package. The new generic machinery is used to represent a request from
  a relying party independent of the actual mechanism (e.g. ISO/IEC
  18013-5:2021 proximity, OpenID4VP, etc) and credential format (e.g.
  ISO mdoc or W3C VC).
- Fix locking issues with MdocTransport so close() works at any point
  of the connection process.
- Switch to the released version of play-services-identity-credentials
  instead of bundling an AAR ourselves.
- Bump compileSdk and targetSdk to 35 (Android 15)

Test: Manually tested.
Test: Multi-Device "All Tests & Corner-cases" pass 100%
Test: New unit tests and all unit tests pass.
Signed-off-by: David Zeuthen <[email protected]>
  • Loading branch information
davidz25 committed Jan 23, 2025
1 parent b94a8b3 commit 4f9b07c
Show file tree
Hide file tree
Showing 105 changed files with 4,171 additions and 1,881 deletions.
5 changes: 3 additions & 2 deletions appholder/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import org.gradle.kotlin.dsl.credentials

plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.compose.compiler)
Expand Down Expand Up @@ -102,8 +104,7 @@ dependencies {
implementation(libs.exifinterface)
implementation(libs.androidx.work)

implementation(files("../third-party/play-services-identity-credentials-0.0.1-eap01.aar"))
implementation(libs.bundles.google.play.services)
implementation(libs.play.services.identity.credentials)

implementation(libs.bouncy.castle.bcprov)
implementation(libs.bouncy.castle.bcpkix)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,11 +168,9 @@ class GetCredentialActivity : FragmentActivity() {
val cmrequest = extractGetCredentialRequest(intent)
val credentialId = intent.getLongExtra(EXTRA_CREDENTIAL_ID, -1).toInt()

// This call is currently broken, have to extract this info manually for now
//val callingAppInfo = extractCallingAppInfo(intent)
val callingPackageName =
intent.getStringExtra(IntentHelper.EXTRA_CALLING_PACKAGE_NAME)!!
val callingOrigin = intent.getStringExtra(IntentHelper.EXTRA_ORIGIN)
val callingAppInfo = IntentHelper.extractCallingAppInfo(intent)!!
val callingPackageName = callingAppInfo.packageName
val callingOrigin = callingAppInfo.origin

log("CredId: $credentialId ${cmrequest!!.credentialOptions.get(0).requestMatcher}")
log("Calling app $callingPackageName $callingOrigin")
Expand Down
5 changes: 3 additions & 2 deletions appverifier/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import org.gradle.kotlin.dsl.credentials

plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.compose.compiler)
Expand Down Expand Up @@ -88,8 +90,7 @@ dependencies {
implementation(libs.kotlinx.io.core)
implementation(libs.cbor)

implementation(files("../third-party/play-services-identity-credentials-0.0.1-eap01.aar"))
implementation(libs.bundles.google.play.services)
implementation(libs.play.services.identity.credentials)

implementation(libs.bouncy.castle.bcprov)
implementation(libs.bouncy.castle.bcpkix)
Expand Down
15 changes: 4 additions & 11 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
[versions]
agp = "8.4.0"
android-compileSdk = "34"
android-compileSdk = "35"
android-minSdk = "26"
android-targetSdk = "34"
android-targetSdk = "35"
androidx-activity-compose = "1.9.3"
androidx-appcompat = "1.6.1"
androidx-constraintlayout = "2.1.4"
Expand Down Expand Up @@ -39,9 +39,6 @@ androidx-lifecycle-ktx = "2.8.2"
compose-runtime-livedata = "1.6.8"
accompanist-permissions = "0.34.0"
work-version = "2.9.0"
play-services-base = "18.2.0"
play-services-basement = "18.2.0"
play-services-tasks = "18.0.2"
nimbus-sdk = "11.8"
zxing = "3.5.3"
gretty = "4.1.4"
Expand All @@ -62,6 +59,7 @@ buildconfig = "5.3.5"
qrose = "1.0.1"
easyqrscan = "0.2.0"
androidx-sqlite = "2.5.0-alpha12"
play-services-identity-credentials = "16.0.0-alpha05"

[libraries]
face-detection = { module = "com.google.mlkit:face-detection", version.ref = "faceDetection" }
Expand Down Expand Up @@ -116,9 +114,6 @@ androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmode
compose-runtime-livedata = { module = "androidx.compose.runtime:runtime-livedata", version.ref = "compose-runtime-livedata" }
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist-permissions"}
androidx-work = { module = "androidx.work:work-runtime-ktx", version.ref = "work-version"}
play-services-base = { module = "com.google.android.gms:play-services-base", version.ref = "play-services-base" }
play-services-basement = { module = "com.google.android.gms:play-services-basement", version.ref = "play-services-basement" }
play-services-tasks = { module = "com.google.android.gms:play-services-tasks", version.ref = "play-services-tasks" }
nimbus-oauth2-oidc-sdk = { module = "com.nimbusds:oauth2-oidc-sdk", version.ref = "nimbus-sdk" }
zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" }
hsqldb = { module = "org.hsqldb:hsqldb", version.ref = "hsqldb" }
Expand All @@ -140,9 +135,7 @@ androidx-sqlite = { module="androidx.sqlite:sqlite", version.ref = "androidx-sql
androidx-sqlite-framework = { module="androidx.sqlite:sqlite-framework", version.ref = "androidx-sqlite" }
androidx-sqlite-bundled = { module="androidx.sqlite:sqlite-bundled", version.ref = "androidx-sqlite" }
postgresql = { module="org.postgresql:postgresql", version.ref = "postgresql" }

[bundles]
google-play-services = ["play-services-base", "play-services-basement", "play-services-tasks"]
play-services-identity-credentials = {module = "com.google.android.gms:play-services-identity-credentials", version.ref = "play-services-identity-credentials"}

[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,10 @@ class CloudSecureAreaTest {
if (packageToAllow != null) {
try {
val pkg = context.packageManager
.getPackageInfo(packageToAllow, PackageManager.GET_SIGNATURES)
.getPackageInfo(packageToAllow, PackageManager.GET_SIGNING_CERTIFICATES)
val digester = MessageDigest.getInstance("SHA-256")
for (n in pkg.signatures.indices) {
digestOfSignatures.add(digester.digest(pkg.signatures[n].toByteArray()))
for (n in pkg.signingInfo!!.apkContentsSigners.indices) {
digestOfSignatures.add(digester.digest(pkg.signingInfo!!.apkContentsSigners[n].toByteArray()))
}
} catch (e: PackageManager.NameNotFoundException) {
throw CloudException(e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ class ConnectionMethodTcp(val host: String, val port: Int) : ConnectionMethod()

override fun toNdefRecord(
auxiliaryReferences: List<String>,
role: MdocTransport.Role
role: MdocTransport.Role,
skipUuids: Boolean
): Pair<NdefRecord, NdefRecord>? {
Logger.w(TAG, "toNdefRecord() not yet implemented")
return null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ class ConnectionMethodUdp(val host: String, val port: Int) : ConnectionMethod()

override fun toNdefRecord(
auxiliaryReferences: List<String>,
role: MdocTransport.Role
role: MdocTransport.Role,
skipUuids: Boolean
): Pair<NdefRecord, NdefRecord>? {
Logger.w(TAG, "toNdefRecord() not yet implemented")
return null
Expand Down
2 changes: 2 additions & 0 deletions identity-appsupport/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ kotlin {
implementation(project(":identity-mdoc"))
implementation(libs.kotlinx.datetime)
implementation(libs.kotlinx.io.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.qrose)
implementation(libs.easyqrscan)
}
Expand All @@ -66,6 +67,7 @@ kotlin {
implementation(libs.tink)
implementation(libs.accompanist.permissions)
implementation(libs.androidx.material)
implementation(libs.play.services.identity.credentials)
}
}
}
Expand Down
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.android.identity.appsupport.credman

import android.graphics.Bitmap
import java.io.ByteArrayOutputStream
import org.json.JSONArray
import org.json.JSONObject

class IdentityCredentialEntry(
val id: Long,
val format: String,
val title: String,
val subtitle: String,
val icon: Bitmap,
val fields: List<IdentityCredentialField>,
val disclaimer: String?,
val warning: String?,
) {
fun getIconBytes(): ByteArrayOutputStream {
val scaledIcon = Bitmap.createScaledBitmap(icon, 128, 128, true)
val stream = ByteArrayOutputStream()
scaledIcon.compress(Bitmap.CompressFormat.PNG, 100, stream)
return stream
}

fun toJson(iconIndex: Int?): JSONObject {
val credential = JSONObject()
credential.put("format", format)
val displayInfo = JSONObject()
displayInfo.put("title", title)
displayInfo.putOpt("subtitle", subtitle)
displayInfo.putOpt("disclaimer", disclaimer)
displayInfo.putOpt("warning", warning)
displayInfo.putOpt("icon_id", iconIndex)
credential.put("display_info", displayInfo)
val fieldsJson = JSONArray()
fields.forEach { fieldsJson.put(it.toJson()) }
credential.put("fields", fieldsJson)

val result = JSONObject()
result.put("id", id)
result.put("credential", credential)
return result
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.android.identity.appsupport.credman

import org.json.JSONObject

class IdentityCredentialField(
val name: String,
val value: Any?,
val displayName: String,
val displayValue: String?,
) {
fun toJson(): JSONObject {
val field = JSONObject()
field.put("name", name)
field.putOpt("value", value)
field.put("display_name", displayName)
field.putOpt("display_value", displayValue)
return field
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.android.identity.appsupport.credman

import android.content.Context
import com.google.android.gms.identitycredentials.RegistrationRequest
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import org.json.JSONArray
import org.json.JSONObject

class IdentityCredentialRegistry(
val entries: List<IdentityCredentialEntry>,
) {
fun toRegistrationRequest(context: Context): RegistrationRequest {

return RegistrationRequest(
credentials = credentialBytes(),
matcher = loadMatcher(context),
type = "com.credman.IdentityCredential",
requestType = "",
protocolTypes = emptyList(),
)
}

private fun loadMatcher(context: Context): ByteArray {
val stream = context.assets.open("identitycredentialmatcher.wasm");
val matcher = ByteArray(stream.available())
stream.read(matcher)
stream.close()
return matcher
}

private fun credentialBytes(): ByteArray {
val json = JSONObject()
val credListJson = JSONArray()
val icons = ByteArrayOutputStream()
val iconSizeList = mutableListOf<Int>()
entries.forEach { entry ->
val iconBytes = entry.getIconBytes()
credListJson.put(entry.toJson(iconSizeList.size))
iconSizeList.add(iconBytes.size())
iconBytes.writeTo(icons)
}
json.put("credentials", credListJson)
val credsBytes = json.toString(0).toByteArray()
val result = ByteArrayOutputStream()
// header_size
result.write(intBytes((3 + iconSizeList.size) * Int.SIZE_BYTES))
// creds_size
result.write(intBytes(credsBytes.size))
// icon_size_array_size
result.write(intBytes(iconSizeList.size))
// icon offsets
iconSizeList.forEach { result.write(intBytes(it)) }
result.write(credsBytes)
icons.writeTo(result)
return result.toByteArray()
}

companion object {
fun intBytes(num: Int): ByteArray =
ByteBuffer.allocate(Int.SIZE_BYTES).order(ByteOrder.LITTLE_ENDIAN).putInt(num).array()
}
}
Loading

0 comments on commit 4f9b07c

Please sign in to comment.