mirror of
https://github.com/selfxyz/self.git
synced 2026-02-19 02:24:25 -05:00
1109 lines
37 KiB
Markdown
1109 lines
37 KiB
Markdown
# Person 2: KMP SDK / Native Handlers — Implementation Spec
|
|
|
|
## Current Status
|
|
|
|
| Chunk | Description | Status |
|
|
|-------|-------------|--------|
|
|
| 2A | KMP Setup + Bridge Protocol | ✅ Complete |
|
|
| 2B | Android WebView Host | ✅ Complete |
|
|
| 2C | Android Native Handlers | ✅ Complete (all 9) |
|
|
| 2D | iOS WebView Host + cinterop | ⚠️ Partial (cinterop blocked by Xcode SDK compatibility issues, stubs in place) |
|
|
| 2E | iOS Native Handlers | ❌ Not Done (all 9 handlers are stubs throwing `NotImplementedError`) |
|
|
| 2F | SDK Public API + Test App | ⚠️ Partial (Android works end-to-end, iOS uses Swift workarounds via factory pattern in test app) |
|
|
|
|
> **Note:** Remaining iOS handler work has moved to [SPEC-IOS-HANDLERS.md](./SPEC-IOS-HANDLERS.md) — uses a Swift wrapper pattern instead of cinterop. The native proving client (for headless SDK use without WebView) is specified in [SPEC-PROVING-CLIENT.md](./SPEC-PROVING-CLIENT.md). A MiniPay sample app demonstrating the headless flow is in [SPEC-MINIPAY-SAMPLE.md](./SPEC-MINIPAY-SAMPLE.md).
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
You are building the **native side** of the Self Mobile SDK. This means:
|
|
|
|
1. **`packages/kmp-sdk/`** — Kotlin Multiplatform module with `shared/` source sets
|
|
2. **`packages/kmp-test-app/`** — Test app for both Android and iOS
|
|
|
|
The KMP SDK:
|
|
- Hosts a WebView containing Person 1's Vite bundle
|
|
- Routes bridge messages from the WebView to native handlers
|
|
- Provides `SelfSdk.launch()` as the public API for host apps (MiniPay, etc.)
|
|
- Outputs: AAR (Android) + XCFramework/SPM (iOS)
|
|
|
|
---
|
|
|
|
## What to Delete First
|
|
|
|
Delete `packages/kmp-shell/` entirely before starting. It was an experiment — the bridge protocol and handler pattern are sound, but the module structure needs to be rebuilt as a proper KMP SDK with Android target (not just JVM + iOS).
|
|
|
|
---
|
|
|
|
## Directory Structure
|
|
|
|
```
|
|
packages/kmp-sdk/
|
|
shared/
|
|
src/
|
|
commonMain/kotlin/xyz/self/sdk/
|
|
bridge/
|
|
BridgeMessage.kt # @Serializable protocol types
|
|
BridgeHandler.kt # Handler interface + BridgeHandlerException
|
|
MessageRouter.kt # Routes messages to handlers, sends responses
|
|
models/
|
|
PassportScanResult.kt # Common NFC result model
|
|
NfcScanProgress.kt # Progress events
|
|
NfcScanParams.kt # Scan parameters
|
|
MrzKeyUtils.kt # MRZ key derivation (pure Kotlin)
|
|
api/
|
|
SelfSdk.kt # expect class — public API
|
|
SelfSdkConfig.kt # Configuration data class
|
|
VerificationRequest.kt # Request model
|
|
SelfSdkCallback.kt # Result callback interface
|
|
webview/
|
|
WebViewHost.kt # expect class — WebView hosting
|
|
|
|
commonTest/kotlin/xyz/self/sdk/
|
|
bridge/
|
|
MessageRouterTest.kt
|
|
models/
|
|
MrzKeyUtilsTest.kt
|
|
|
|
androidMain/kotlin/xyz/self/sdk/
|
|
api/
|
|
SelfSdk.android.kt # actual class — Android implementation
|
|
webview/
|
|
AndroidWebViewHost.kt # Android WebView + JS injection
|
|
SelfVerificationActivity.kt # Activity wrapping the WebView
|
|
handlers/
|
|
NfcBridgeHandler.kt # JMRTD passport reader
|
|
BiometricBridgeHandler.kt # BiometricPrompt
|
|
SecureStorageBridgeHandler.kt # EncryptedSharedPreferences
|
|
CryptoBridgeHandler.kt # Java Security Provider
|
|
CameraMrzBridgeHandler.kt # ML Kit Text Recognition
|
|
HapticBridgeHandler.kt # Vibration feedback
|
|
AnalyticsBridgeHandler.kt # Fire-and-forget logging
|
|
LifecycleBridgeHandler.kt # WebView → host communication
|
|
DocumentsBridgeHandler.kt # Encrypted document storage
|
|
|
|
iosMain/kotlin/xyz/self/sdk/
|
|
api/
|
|
SelfSdk.ios.kt # actual class — iOS implementation
|
|
webview/
|
|
IosWebViewHost.kt # WKWebView + JS injection
|
|
handlers/
|
|
NfcBridgeHandler.kt # CoreNFC via cinterop
|
|
BiometricBridgeHandler.kt # LAContext via cinterop
|
|
SecureStorageBridgeHandler.kt # Keychain via cinterop
|
|
CryptoBridgeHandler.kt # CommonCrypto via cinterop
|
|
CameraMrzBridgeHandler.kt # Vision framework via cinterop
|
|
HapticBridgeHandler.kt # UIImpactFeedbackGenerator
|
|
AnalyticsBridgeHandler.kt # Fire-and-forget logging
|
|
LifecycleBridgeHandler.kt # WebView → host communication
|
|
DocumentsBridgeHandler.kt # Encrypted document storage
|
|
|
|
nativeInterop/
|
|
cinterop/
|
|
CoreNFC.def
|
|
LocalAuthentication.def
|
|
Security.def
|
|
Vision.def
|
|
|
|
build.gradle.kts # KMP plugin, Android + iOS targets
|
|
|
|
packages/kmp-test-app/
|
|
shared/ # Shared KMP app code
|
|
androidApp/ # Android test app (Compose)
|
|
iosApp/ # iOS test app (SwiftUI)
|
|
build.gradle.kts
|
|
```
|
|
|
|
---
|
|
|
|
## Gradle Configuration
|
|
|
|
### `packages/kmp-sdk/build.gradle.kts`
|
|
|
|
```kotlin
|
|
plugins {
|
|
alias(libs.plugins.kotlinMultiplatform)
|
|
alias(libs.plugins.kotlinSerialization)
|
|
alias(libs.plugins.androidLibrary) // NEW: Android library target
|
|
id("maven-publish") // For AAR publishing
|
|
}
|
|
|
|
kotlin {
|
|
jvm() // For unit tests on JVM
|
|
|
|
androidTarget {
|
|
compilations.all {
|
|
kotlinOptions {
|
|
jvmTarget = "17"
|
|
}
|
|
}
|
|
publishLibraryVariants("release")
|
|
}
|
|
|
|
iosArm64()
|
|
iosSimulatorArm64()
|
|
|
|
// iOS framework for SPM distribution
|
|
listOf(iosArm64(), iosSimulatorArm64()).forEach {
|
|
it.binaries.framework {
|
|
baseName = "SelfSdk"
|
|
isStatic = true
|
|
}
|
|
}
|
|
|
|
sourceSets {
|
|
commonMain.dependencies {
|
|
implementation(libs.kotlinx.coroutines.core)
|
|
implementation(libs.kotlinx.serialization.json)
|
|
}
|
|
commonTest.dependencies {
|
|
implementation(libs.kotlin.test)
|
|
implementation(libs.kotlinx.coroutines.test)
|
|
}
|
|
val androidMain by getting {
|
|
dependencies {
|
|
// WebView
|
|
implementation("androidx.webkit:webkit:1.12.1")
|
|
// NFC / Passport
|
|
implementation("org.jmrtd:jmrtd:0.8.1")
|
|
implementation("net.sf.scuba:scuba-sc-android:0.0.18")
|
|
implementation("org.bouncycastle:bcprov-jdk18on:1.78.1")
|
|
implementation("commons-io:commons-io:2.14.0")
|
|
// Biometrics
|
|
implementation("androidx.biometric:biometric:1.2.0-alpha05")
|
|
// Encrypted storage
|
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
|
// Camera / MRZ
|
|
implementation("com.google.mlkit:text-recognition:16.0.0")
|
|
// Activity / Lifecycle
|
|
implementation("androidx.appcompat:appcompat:1.7.0")
|
|
implementation("androidx.activity:activity-ktx:1.9.3")
|
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
android {
|
|
namespace = "xyz.self.sdk"
|
|
compileSdk = 35
|
|
defaultConfig {
|
|
minSdk = 24
|
|
}
|
|
compileOptions {
|
|
sourceCompatibility = JavaVersion.VERSION_17
|
|
targetCompatibility = JavaVersion.VERSION_17
|
|
}
|
|
// Bundle WebView assets
|
|
sourceSets["main"].assets.srcDirs("src/main/assets")
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Bridge Protocol (Kotlin Side)
|
|
|
|
The bridge protocol is the shared contract with Person 1. The Kotlin implementation mirrors the TypeScript types exactly.
|
|
|
|
### BridgeMessage.kt
|
|
|
|
```kotlin
|
|
package xyz.self.sdk.bridge
|
|
|
|
import kotlinx.serialization.*
|
|
import kotlinx.serialization.json.JsonElement
|
|
|
|
const val BRIDGE_PROTOCOL_VERSION = 1
|
|
|
|
@Serializable
|
|
enum class BridgeDomain {
|
|
@SerialName("nfc") NFC,
|
|
@SerialName("biometrics") BIOMETRICS,
|
|
@SerialName("secureStorage") SECURE_STORAGE,
|
|
@SerialName("camera") CAMERA,
|
|
@SerialName("crypto") CRYPTO,
|
|
@SerialName("haptic") HAPTIC,
|
|
@SerialName("analytics") ANALYTICS,
|
|
@SerialName("lifecycle") LIFECYCLE,
|
|
@SerialName("documents") DOCUMENTS,
|
|
@SerialName("navigation") NAVIGATION,
|
|
}
|
|
|
|
@Serializable
|
|
data class BridgeError(
|
|
val code: String,
|
|
val message: String,
|
|
val details: Map<String, JsonElement>? = null,
|
|
)
|
|
|
|
@Serializable
|
|
data class BridgeRequest(
|
|
val type: String = "request",
|
|
val version: Int,
|
|
val id: String,
|
|
val domain: BridgeDomain,
|
|
val method: String,
|
|
val params: Map<String, JsonElement>,
|
|
val timestamp: Long,
|
|
)
|
|
|
|
@Serializable
|
|
data class BridgeResponse(
|
|
val type: String = "response",
|
|
val version: Int = BRIDGE_PROTOCOL_VERSION,
|
|
val id: String,
|
|
val domain: BridgeDomain,
|
|
val requestId: String,
|
|
val success: Boolean,
|
|
val data: JsonElement? = null,
|
|
val error: BridgeError? = null,
|
|
val timestamp: Long = currentTimeMillis(),
|
|
)
|
|
|
|
@Serializable
|
|
data class BridgeEvent(
|
|
val type: String = "event",
|
|
val version: Int = BRIDGE_PROTOCOL_VERSION,
|
|
val id: String,
|
|
val domain: BridgeDomain,
|
|
val event: String,
|
|
val data: JsonElement,
|
|
val timestamp: Long = currentTimeMillis(),
|
|
)
|
|
|
|
// Platform expect/actual for time and UUID
|
|
internal expect fun currentTimeMillis(): Long
|
|
internal expect fun generateUuid(): String
|
|
```
|
|
|
|
**Platform actuals:**
|
|
- **JVM/Android:** `System.currentTimeMillis()`, `java.util.UUID.randomUUID().toString()`
|
|
- **iOS:** `NSDate().timeIntervalSince1970 * 1000`, `NSUUID().UUIDString`
|
|
|
|
### BridgeHandler.kt
|
|
|
|
```kotlin
|
|
interface BridgeHandler {
|
|
val domain: BridgeDomain
|
|
suspend fun handle(method: String, params: Map<String, JsonElement>): JsonElement?
|
|
}
|
|
|
|
class BridgeHandlerException(
|
|
val code: String,
|
|
override val message: String,
|
|
val details: Map<String, JsonElement>? = null,
|
|
) : Exception(message)
|
|
```
|
|
|
|
### MessageRouter.kt
|
|
|
|
Routes incoming messages from WebView to handlers, runs them on a coroutine scope, sends responses back via a `sendToWebView` callback.
|
|
|
|
Key behavior:
|
|
- `register(handler)`: Register a `BridgeHandler` for a domain
|
|
- `onMessageReceived(rawJson)`: Parse request, find handler, dispatch on coroutine scope
|
|
- `pushEvent(domain, event, data)`: Send unsolicited events to WebView
|
|
- Response delivery: `window.SelfNativeBridge._handleResponse('...')`
|
|
- Event delivery: `window.SelfNativeBridge._handleEvent('...')`
|
|
|
|
**JS escaping** for safe embedding:
|
|
```kotlin
|
|
fun escapeForJs(json: String): String {
|
|
val escaped = json
|
|
.replace("\\", "\\\\")
|
|
.replace("'", "\\'")
|
|
.replace("\n", "\\n")
|
|
.replace("\r", "\\r")
|
|
return "'$escaped'"
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Android Implementation
|
|
|
|
### AndroidWebViewHost.kt
|
|
|
|
Manages an Android `WebView` instance:
|
|
|
|
```kotlin
|
|
class AndroidWebViewHost(
|
|
private val context: Context,
|
|
private val router: MessageRouter,
|
|
) {
|
|
private lateinit var webView: WebView
|
|
|
|
fun createWebView(): WebView {
|
|
webView = WebView(context).apply {
|
|
settings.apply {
|
|
javaScriptEnabled = true
|
|
domStorageEnabled = true
|
|
allowFileAccess = false // Security
|
|
allowContentAccess = false
|
|
mediaPlaybackRequiresUserGesture = false
|
|
}
|
|
|
|
// JS interface: WebView → Native
|
|
addJavascriptInterface(BridgeJsInterface(), "SelfNativeAndroid")
|
|
|
|
// Load bundled assets or dev server
|
|
if (BuildConfig.DEBUG) {
|
|
loadUrl("http://10.0.2.2:5173")
|
|
} else {
|
|
loadUrl("file:///android_asset/self-wallet/index.html")
|
|
}
|
|
}
|
|
return webView
|
|
}
|
|
|
|
// Send response/event to WebView
|
|
fun evaluateJs(js: String) {
|
|
webView.evaluateJavascript(js, null)
|
|
}
|
|
|
|
inner class BridgeJsInterface {
|
|
@JavascriptInterface
|
|
fun postMessage(json: String) {
|
|
router.onMessageReceived(json)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### SelfVerificationActivity.kt
|
|
|
|
An Activity that hosts the WebView. Host apps launch this via `SelfSdk.launch()`:
|
|
|
|
```kotlin
|
|
class SelfVerificationActivity : AppCompatActivity() {
|
|
private lateinit var webViewHost: AndroidWebViewHost
|
|
private lateinit var router: MessageRouter
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
super.onCreate(savedInstanceState)
|
|
|
|
// Create router with callback to send JS to WebView
|
|
router = MessageRouter(
|
|
sendToWebView = { js -> runOnUiThread { webViewHost.evaluateJs(js) } }
|
|
)
|
|
|
|
// Register all native handlers
|
|
router.register(NfcBridgeHandler(this, router))
|
|
router.register(BiometricBridgeHandler(this))
|
|
router.register(SecureStorageBridgeHandler(this))
|
|
router.register(CryptoBridgeHandler())
|
|
router.register(CameraMrzBridgeHandler(this))
|
|
router.register(HapticBridgeHandler(this))
|
|
router.register(AnalyticsBridgeHandler())
|
|
router.register(LifecycleBridgeHandler(this))
|
|
router.register(DocumentsBridgeHandler(this))
|
|
|
|
// Create and show WebView
|
|
webViewHost = AndroidWebViewHost(this, router)
|
|
setContentView(webViewHost.createWebView())
|
|
}
|
|
}
|
|
```
|
|
|
|
### NfcBridgeHandler.kt (Android)
|
|
|
|
**This is the most complex handler.** Port from `app/android/react-native-passport-reader/android/src/main/java/io/tradle/nfc/RNPassportReaderModule.kt`.
|
|
|
|
Key changes from the RN module:
|
|
1. Remove all React Native dependencies (`ReactApplicationContext`, `Promise`, `WritableMap`, `ReadableMap`, `DeviceEventManagerModule`)
|
|
2. Replace `AsyncTask` with Kotlin coroutines (`suspend fun`)
|
|
3. Use `NfcAdapter.enableReaderMode()` instead of `enableForegroundDispatch()` (better for SDK embedding — doesn't require the host's Activity to handle intents)
|
|
4. Send progress updates via `router.pushEvent()` instead of React Native event emitter
|
|
5. Return structured `PassportScanResult` instead of React Native `WritableMap`
|
|
|
|
```kotlin
|
|
class NfcBridgeHandler(
|
|
private val activity: Activity,
|
|
private val router: MessageRouter,
|
|
) : BridgeHandler {
|
|
|
|
override val domain = BridgeDomain.NFC
|
|
|
|
override suspend fun handle(method: String, params: Map<String, JsonElement>): JsonElement? {
|
|
return when (method) {
|
|
"scan" -> scan(params)
|
|
"cancelScan" -> cancelScan()
|
|
"isSupported" -> isSupported()
|
|
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown NFC method: $method")
|
|
}
|
|
}
|
|
|
|
private suspend fun scan(params: Map<String, JsonElement>): JsonElement {
|
|
val scanParams = Json.decodeFromJsonElement<NfcScanParams>(JsonObject(params))
|
|
|
|
// Derive BAC key from MRZ data
|
|
val mrzKey = MrzKeyUtils.computeMrzInfo(
|
|
scanParams.passportNumber,
|
|
scanParams.dateOfBirth,
|
|
scanParams.dateOfExpiry,
|
|
)
|
|
|
|
// Wait for NFC tag using enableReaderMode (coroutine-friendly)
|
|
val tag = awaitNfcTag()
|
|
|
|
// Open IsoDep connection
|
|
val isoDep = IsoDep.get(tag)
|
|
isoDep.timeout = 20_000
|
|
|
|
try {
|
|
val cardService = CardService.getInstance(isoDep)
|
|
cardService.open()
|
|
|
|
val service = PassportService(
|
|
cardService,
|
|
PassportService.NORMAL_MAX_TRANCEIVE_LENGTH * 2,
|
|
PassportService.DEFAULT_MAX_BLOCKSIZE * 2,
|
|
false, false,
|
|
)
|
|
service.open()
|
|
|
|
// PACE attempt
|
|
pushProgress("pace", 10, "Attempting PACE authentication...")
|
|
var paceSucceeded = tryPACE(service, scanParams)
|
|
|
|
// BAC fallback
|
|
if (!paceSucceeded) {
|
|
pushProgress("bac", 20, "Attempting BAC authentication...")
|
|
val bacKey = BACKey(scanParams.passportNumber, scanParams.dateOfBirth, scanParams.dateOfExpiry)
|
|
tryBAC(service, bacKey)
|
|
}
|
|
|
|
// Read data groups
|
|
pushProgress("reading_dg1", 40, "Reading DG1...")
|
|
val dg1File = DG1File(service.getInputStream(PassportService.EF_DG1))
|
|
|
|
pushProgress("reading_sod", 60, "Reading SOD...")
|
|
val sodFile = SODFile(service.getInputStream(PassportService.EF_SOD))
|
|
|
|
// Chip authentication
|
|
pushProgress("chip_auth", 80, "Chip authentication...")
|
|
doChipAuth(service)
|
|
|
|
pushProgress("complete", 100, "Scan complete")
|
|
|
|
// Build result matching PassportScanResult
|
|
return buildPassportResult(dg1File, sodFile)
|
|
|
|
} finally {
|
|
isoDep.close()
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**NFC flow (from RNPassportReaderModule, simplified):**
|
|
|
|
1. Get `NfcAdapter`, check `isEnabled`
|
|
2. Wait for tag via `enableReaderMode` (or `enableForegroundDispatch`)
|
|
3. Get `IsoDep` from tag, set timeout to 20s
|
|
4. Create `CardService`, open it
|
|
5. Create `PassportService`, open it
|
|
6. **PACE attempt**: Read `EF_CARD_ACCESS` → extract `PACEInfo` → `service.doPACE()`
|
|
7. **BAC fallback** (if PACE fails): `service.sendSelectApplet(false)` → `service.doBAC(bacKey)` with up to 3 retries
|
|
8. **Select applet** after auth: `service.sendSelectApplet(true)`
|
|
9. **Read DG1**: `DG1File(service.getInputStream(PassportService.EF_DG1))`
|
|
10. **Read SOD**: `SODFile(service.getInputStream(PassportService.EF_SOD))`
|
|
11. **Chip Authentication**: Read DG14 → extract `ChipAuthenticationPublicKeyInfo` → `service.doEACCA()`
|
|
12. **Build result**: Extract MRZ, certificates, hashes, signatures from parsed files
|
|
|
|
**Dependencies:**
|
|
- `org.jmrtd:jmrtd:0.8.1`
|
|
- `net.sf.scuba:scuba-sc-android:0.0.18`
|
|
- `org.bouncycastle:bcprov-jdk18on:1.78.1`
|
|
- `commons-io:commons-io:2.14.0`
|
|
|
|
### BiometricBridgeHandler.kt (Android)
|
|
|
|
```kotlin
|
|
class BiometricBridgeHandler(private val activity: FragmentActivity) : BridgeHandler {
|
|
override val domain = BridgeDomain.BIOMETRICS
|
|
|
|
override suspend fun handle(method: String, params: Map<String, JsonElement>): JsonElement? {
|
|
return when (method) {
|
|
"authenticate" -> authenticate(params)
|
|
"isAvailable" -> isAvailable()
|
|
"getBiometryType" -> getBiometryType()
|
|
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown biometrics method: $method")
|
|
}
|
|
}
|
|
|
|
private suspend fun authenticate(params: Map<String, JsonElement>): JsonElement {
|
|
val reason = params["reason"]?.jsonPrimitive?.content ?: "Authenticate"
|
|
return suspendCancellableCoroutine { cont ->
|
|
val promptInfo = BiometricPrompt.PromptInfo.Builder()
|
|
.setTitle("Self Verification")
|
|
.setSubtitle(reason)
|
|
.setNegativeButtonText("Cancel")
|
|
.build()
|
|
|
|
val prompt = BiometricPrompt(activity, /* executor */, object : BiometricPrompt.AuthenticationCallback() {
|
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
|
cont.resume(JsonPrimitive(true))
|
|
}
|
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
|
cont.resumeWithException(BridgeHandlerException("BIOMETRIC_ERROR", errString.toString()))
|
|
}
|
|
override fun onAuthenticationFailed() {
|
|
cont.resumeWithException(BridgeHandlerException("BIOMETRIC_FAILED", "Authentication failed"))
|
|
}
|
|
})
|
|
prompt.authenticate(promptInfo)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### SecureStorageBridgeHandler.kt (Android)
|
|
|
|
Uses `EncryptedSharedPreferences` backed by Android Keystore:
|
|
|
|
```kotlin
|
|
class SecureStorageBridgeHandler(context: Context) : BridgeHandler {
|
|
override val domain = BridgeDomain.SECURE_STORAGE
|
|
|
|
private val prefs = EncryptedSharedPreferences.create(
|
|
"self_sdk_secure_prefs",
|
|
MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC),
|
|
context,
|
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
|
)
|
|
|
|
override suspend fun handle(method: String, params: Map<String, JsonElement>): JsonElement? {
|
|
val key = params["key"]?.jsonPrimitive?.content
|
|
?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required")
|
|
|
|
return when (method) {
|
|
"get" -> {
|
|
val value = prefs.getString(key, null)
|
|
if (value != null) JsonPrimitive(value) else JsonNull
|
|
}
|
|
"set" -> {
|
|
val value = params["value"]?.jsonPrimitive?.content
|
|
?: throw BridgeHandlerException("MISSING_VALUE", "Value parameter required")
|
|
prefs.edit().putString(key, value).apply()
|
|
null
|
|
}
|
|
"remove" -> {
|
|
prefs.edit().remove(key).apply()
|
|
null
|
|
}
|
|
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown secureStorage method: $method")
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### CryptoBridgeHandler.kt (Android)
|
|
|
|
```kotlin
|
|
class CryptoBridgeHandler : BridgeHandler {
|
|
override val domain = BridgeDomain.CRYPTO
|
|
|
|
override suspend fun handle(method: String, params: Map<String, JsonElement>): JsonElement? {
|
|
return when (method) {
|
|
"sign" -> sign(params)
|
|
"generateKey" -> generateKey(params)
|
|
"getPublicKey" -> getPublicKey(params)
|
|
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown crypto method: $method")
|
|
}
|
|
}
|
|
|
|
private fun sign(params: Map<String, JsonElement>): JsonElement {
|
|
val dataBase64 = params["data"]?.jsonPrimitive?.content
|
|
?: throw BridgeHandlerException("MISSING_DATA", "Data parameter required")
|
|
val keyRef = params["keyRef"]?.jsonPrimitive?.content
|
|
?: throw BridgeHandlerException("MISSING_KEY_REF", "keyRef parameter required")
|
|
|
|
val data = Base64.decode(dataBase64, Base64.NO_WRAP)
|
|
|
|
// Load key from Android Keystore
|
|
val keyStore = KeyStore.getInstance("AndroidKeyStore")
|
|
keyStore.load(null)
|
|
val entry = keyStore.getEntry(keyRef, null) as? KeyStore.PrivateKeyEntry
|
|
?: throw BridgeHandlerException("KEY_NOT_FOUND", "Key not found: $keyRef")
|
|
|
|
val signature = Signature.getInstance("SHA256withECDSA")
|
|
signature.initSign(entry.privateKey)
|
|
signature.update(data)
|
|
val signed = signature.sign()
|
|
|
|
return buildJsonObject {
|
|
put("signature", Base64.encodeToString(signed, Base64.NO_WRAP))
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### Other Android Handlers (simpler)
|
|
|
|
**HapticBridgeHandler**: `Vibrator.vibrate(VibrationEffect.createOneShot(...))`
|
|
|
|
**AnalyticsBridgeHandler**: Log to Logcat or forward to host app's analytics. Fire-and-forget (always return null).
|
|
|
|
**LifecycleBridgeHandler**: `ready` = no-op, `dismiss` = `activity.finish()`, `setResult` = set Activity result and finish.
|
|
|
|
**DocumentsBridgeHandler**: Uses `EncryptedSharedPreferences` to store JSON-serialized documents.
|
|
|
|
**CameraMrzBridgeHandler**: Uses ML Kit `TextRecognition` to detect MRZ text from camera preview.
|
|
|
|
---
|
|
|
|
## iOS Implementation
|
|
|
|
### Kotlin/Native cinterop
|
|
|
|
iOS handlers are written in Kotlin using `cinterop` to call Apple frameworks.
|
|
|
|
#### CoreNFC.def
|
|
|
|
```
|
|
language = Objective-C
|
|
headers =
|
|
modules = CoreNFC
|
|
linkerOpts = -framework CoreNFC
|
|
```
|
|
|
|
#### LocalAuthentication.def
|
|
|
|
```
|
|
language = Objective-C
|
|
modules = LocalAuthentication
|
|
linkerOpts = -framework LocalAuthentication
|
|
```
|
|
|
|
#### Security.def
|
|
|
|
```
|
|
language = Objective-C
|
|
modules = Security
|
|
linkerOpts = -framework Security
|
|
```
|
|
|
|
#### Vision.def (for MRZ scanning)
|
|
|
|
```
|
|
language = Objective-C
|
|
modules = Vision
|
|
linkerOpts = -framework Vision
|
|
```
|
|
|
|
Add to `build.gradle.kts`:
|
|
```kotlin
|
|
iosArm64 {
|
|
compilations["main"].cinterops {
|
|
create("CoreNFC")
|
|
create("LocalAuthentication")
|
|
create("Security")
|
|
create("Vision")
|
|
}
|
|
}
|
|
iosSimulatorArm64 {
|
|
compilations["main"].cinterops {
|
|
create("CoreNFC") // Note: NFC won't work on simulator, but it needs to compile
|
|
create("LocalAuthentication")
|
|
create("Security")
|
|
create("Vision")
|
|
}
|
|
}
|
|
```
|
|
|
|
### IosWebViewHost.kt
|
|
|
|
```kotlin
|
|
import platform.WebKit.*
|
|
import platform.Foundation.*
|
|
|
|
actual class IosWebViewHost {
|
|
private lateinit var webView: WKWebView
|
|
|
|
fun createWebView(): WKWebView {
|
|
val config = WKWebViewConfiguration()
|
|
|
|
// Register message handler: WebView → Native
|
|
val handler = BridgeMessageHandler(router)
|
|
config.userContentController.addScriptMessageHandler(handler, "SelfNativeIOS")
|
|
|
|
webView = WKWebView(frame = CGRectZero, configuration = config)
|
|
|
|
// Load bundled HTML from framework resources
|
|
val bundleUrl = NSBundle.mainBundle.URLForResource("self-wallet/index", withExtension = "html")
|
|
if (bundleUrl != null) {
|
|
webView.loadFileURL(bundleUrl, allowingReadAccessToURL = bundleUrl.URLByDeletingLastPathComponent!!)
|
|
}
|
|
|
|
return webView
|
|
}
|
|
|
|
fun evaluateJs(js: String) {
|
|
webView.evaluateJavaScript(js, completionHandler = null)
|
|
}
|
|
}
|
|
|
|
class BridgeMessageHandler(private val router: MessageRouter) : NSObject(), WKScriptMessageHandlerProtocol {
|
|
override fun userContentController(
|
|
userContentController: WKUserContentController,
|
|
didReceiveScriptMessage: WKScriptMessage,
|
|
) {
|
|
val body = didReceiveScriptMessage.body as? String ?: return
|
|
router.onMessageReceived(body)
|
|
}
|
|
}
|
|
```
|
|
|
|
### NfcBridgeHandler.kt (iOS)
|
|
|
|
**Important:** iOS NFC passport reading is significantly more complex than Android because:
|
|
1. CoreNFC is Objective-C/Swift and the Kotlin/Native interop can be tricky
|
|
2. The existing `app/ios/PassportReader.swift` uses the third-party `NFCPassportReader` Swift library (CocoaPod)
|
|
3. Pure Kotlin/Native CoreNFC interop for passport reading (PACE, BAC, data group parsing) is very hard
|
|
|
|
**Recommended approach:** Create a thin Objective-C/Swift wrapper exposed via `@objc` that Kotlin can call through cinterop. The wrapper does the heavy lifting (calling `NFCPassportReader` library), and the Kotlin handler just bridges the JSON params.
|
|
|
|
Alternatively, if you want pure Kotlin, you'd need to implement the entire ICAO 9303 protocol (BAC, PACE, secure messaging, ASN.1 parsing) which is months of work. The pragmatic approach is:
|
|
|
|
```kotlin
|
|
// iOS NFC handler — calls into Swift helper via cinterop
|
|
class NfcBridgeHandler(private val router: MessageRouter) : BridgeHandler {
|
|
override val domain = BridgeDomain.NFC
|
|
|
|
override suspend fun handle(method: String, params: Map<String, JsonElement>): JsonElement? {
|
|
return when (method) {
|
|
"scan" -> scan(params)
|
|
"cancelScan" -> null // NFCPassportReader handles its own UI/cancel
|
|
"isSupported" -> JsonPrimitive(NFCReaderSession.readingAvailable)
|
|
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown NFC method: $method")
|
|
}
|
|
}
|
|
|
|
private suspend fun scan(params: Map<String, JsonElement>): JsonElement {
|
|
// Parse params, call into NFCPassportReaderWrapper (ObjC-exposed Swift)
|
|
// The wrapper returns a JSON string with passport data
|
|
// Parse and return as JsonElement
|
|
}
|
|
}
|
|
```
|
|
|
|
**Reference:** The iOS flow from `app/ios/PassportReader.swift`:
|
|
1. Compute MRZ key (pad, checksum — same as Kotlin `MrzKeyUtils`)
|
|
2. Call `passportReader.readPassport(password: mrzKey, type: .mrz, tags: [.COM, .DG1, .SOD])`
|
|
3. Extract fields from passport object (documentType, MRZ, certificates, etc.)
|
|
4. Extract SOD data: `sod.getEncapsulatedContent()`, `sod.getSignedAttributes()`, `sod.getSignature()`
|
|
5. Return structured result
|
|
|
|
### BiometricBridgeHandler.kt (iOS)
|
|
|
|
```kotlin
|
|
import platform.LocalAuthentication.*
|
|
|
|
class BiometricBridgeHandler : BridgeHandler {
|
|
override val domain = BridgeDomain.BIOMETRICS
|
|
|
|
override suspend fun handle(method: String, params: Map<String, JsonElement>): JsonElement? {
|
|
return when (method) {
|
|
"authenticate" -> authenticate(params)
|
|
"isAvailable" -> isAvailable()
|
|
"getBiometryType" -> getBiometryType()
|
|
else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown method: $method")
|
|
}
|
|
}
|
|
|
|
private suspend fun authenticate(params: Map<String, JsonElement>): JsonElement {
|
|
val reason = params["reason"]?.jsonPrimitive?.content ?: "Authenticate"
|
|
val context = LAContext()
|
|
|
|
return suspendCancellableCoroutine { cont ->
|
|
context.evaluatePolicy(
|
|
LAPolicy.LAPolicyDeviceOwnerAuthenticationWithBiometrics,
|
|
localizedReason = reason,
|
|
) { success, error ->
|
|
if (success) {
|
|
cont.resume(JsonPrimitive(true))
|
|
} else {
|
|
cont.resumeWithException(
|
|
BridgeHandlerException("BIOMETRIC_ERROR", error?.localizedDescription ?: "Unknown error")
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun isAvailable(): JsonElement {
|
|
val context = LAContext()
|
|
val canEvaluate = context.canEvaluatePolicy(LAPolicy.LAPolicyDeviceOwnerAuthenticationWithBiometrics, error = null)
|
|
return JsonPrimitive(canEvaluate)
|
|
}
|
|
|
|
private fun getBiometryType(): JsonElement {
|
|
val context = LAContext()
|
|
context.canEvaluatePolicy(LAPolicy.LAPolicyDeviceOwnerAuthenticationWithBiometrics, error = null)
|
|
return when (context.biometryType) {
|
|
LABiometryType.LABiometryTypeFaceID -> JsonPrimitive("faceId")
|
|
LABiometryType.LABiometryTypeTouchID -> JsonPrimitive("touchId")
|
|
else -> JsonPrimitive("none")
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### SecureStorageBridgeHandler.kt (iOS)
|
|
|
|
Uses Keychain Services via Security framework cinterop:
|
|
|
|
```kotlin
|
|
import platform.Security.*
|
|
import platform.Foundation.*
|
|
|
|
class SecureStorageBridgeHandler : BridgeHandler {
|
|
override val domain = BridgeDomain.SECURE_STORAGE
|
|
|
|
// Keychain operations using SecItemAdd, SecItemCopyMatching, SecItemUpdate, SecItemDelete
|
|
// with kSecClassGenericPassword, kSecAttrService = "xyz.self.sdk", kSecAttrAccount = key
|
|
}
|
|
```
|
|
|
|
### CryptoBridgeHandler.kt (iOS)
|
|
|
|
Uses CommonCrypto or Security framework for signing:
|
|
|
|
```kotlin
|
|
import platform.Security.*
|
|
|
|
class CryptoBridgeHandler : BridgeHandler {
|
|
override val domain = BridgeDomain.CRYPTO
|
|
|
|
// Use SecKeyCreateSignature for signing
|
|
// Keys stored in Keychain with kSecAttrKeyTypeECSECPrimeRandom
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Public API
|
|
|
|
### SelfSdk.kt (commonMain — expect)
|
|
|
|
```kotlin
|
|
expect class SelfSdk {
|
|
companion object {
|
|
fun configure(config: SelfSdkConfig): SelfSdk
|
|
}
|
|
|
|
fun launch(request: VerificationRequest, callback: SelfSdkCallback)
|
|
}
|
|
```
|
|
|
|
### SelfSdkConfig.kt
|
|
|
|
```kotlin
|
|
data class SelfSdkConfig(
|
|
val endpoint: String = "https://api.self.xyz",
|
|
val debug: Boolean = false,
|
|
)
|
|
```
|
|
|
|
### VerificationRequest.kt
|
|
|
|
```kotlin
|
|
data class VerificationRequest(
|
|
val userId: String? = null,
|
|
val scope: String? = null,
|
|
val disclosures: List<String> = emptyList(),
|
|
)
|
|
```
|
|
|
|
### SelfSdkCallback.kt
|
|
|
|
```kotlin
|
|
interface SelfSdkCallback {
|
|
fun onSuccess(result: VerificationResult)
|
|
fun onFailure(error: SelfSdkError)
|
|
fun onCancelled()
|
|
}
|
|
|
|
data class VerificationResult(
|
|
val success: Boolean,
|
|
val userId: String?,
|
|
val verificationId: String?,
|
|
val proof: String?,
|
|
val claims: Map<String, String>?,
|
|
)
|
|
|
|
data class SelfSdkError(
|
|
val code: String,
|
|
val message: String,
|
|
)
|
|
```
|
|
|
|
### SelfSdk.android.kt (actual)
|
|
|
|
```kotlin
|
|
actual class SelfSdk private constructor(private val config: SelfSdkConfig) {
|
|
actual companion object {
|
|
actual fun configure(config: SelfSdkConfig): SelfSdk = SelfSdk(config)
|
|
}
|
|
|
|
actual fun launch(request: VerificationRequest, callback: SelfSdkCallback) {
|
|
// Start SelfVerificationActivity
|
|
// Pass request via Intent extras
|
|
// Register ActivityResult callback to receive result
|
|
// Call callback.onSuccess/onFailure/onCancelled based on result
|
|
}
|
|
}
|
|
```
|
|
|
|
### SelfSdk.ios.kt (actual)
|
|
|
|
```kotlin
|
|
actual class SelfSdk private constructor(private val config: SelfSdkConfig) {
|
|
actual companion object {
|
|
actual fun configure(config: SelfSdkConfig): SelfSdk = SelfSdk(config)
|
|
}
|
|
|
|
actual fun launch(request: VerificationRequest, callback: SelfSdkCallback) {
|
|
// Create UIViewController with WKWebView
|
|
// Present it modally from the current UIViewController
|
|
// Register lifecycle handler to receive setResult and deliver via callback
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Common Models (from prototype — keep as-is)
|
|
|
|
### MrzKeyUtils.kt
|
|
|
|
Pure Kotlin, already correct in the prototype. ICAO 9303 check digit computation with `[7, 3, 1]` weighting.
|
|
|
|
### PassportScanResult.kt / NfcScanProgress.kt / NfcScanParams.kt
|
|
|
|
`@Serializable` data classes matching the TypeScript types in the bridge protocol spec. Already correct in the prototype.
|
|
|
|
---
|
|
|
|
## Asset Bundling
|
|
|
|
### How WebView HTML gets into the SDK
|
|
|
|
**Android:** Gradle task copies Vite output (`dist/`) into `src/main/assets/self-wallet/`:
|
|
|
|
```kotlin
|
|
// In build.gradle.kts
|
|
tasks.register<Copy>("copyWebViewAssets") {
|
|
from("../../packages/webview-app/dist")
|
|
into("src/main/assets/self-wallet")
|
|
}
|
|
tasks.named("preBuild") { dependsOn("copyWebViewAssets") }
|
|
```
|
|
|
|
**iOS:** XCFramework/SPM includes the bundle as a resource bundle.
|
|
|
|
**Dev mode:** Load from `http://10.0.2.2:5173` (Android emulator) or `http://localhost:5173` (iOS simulator) instead of bundled assets.
|
|
|
|
---
|
|
|
|
## Chunking Guide (Claude Code Sessions)
|
|
|
|
### Chunk 2A: KMP Project Setup + Bridge Protocol (start here)
|
|
|
|
**Goal:** Create `packages/kmp-sdk/` with Gradle KMP config, bridge protocol, common models.
|
|
|
|
**Steps:**
|
|
1. Delete `packages/kmp-shell/`
|
|
2. Create `packages/kmp-sdk/` directory structure
|
|
3. Create `build.gradle.kts` with KMP plugin, Android + iOS targets
|
|
4. Create `settings.gradle.kts`, `gradle.properties`, `libs.versions.toml`
|
|
5. Implement `commonMain/bridge/` — BridgeMessage, BridgeHandler, MessageRouter
|
|
6. Implement `commonMain/models/` — MrzKeyUtils, PassportScanResult, NfcScanParams, NfcScanProgress
|
|
7. Implement platform actuals (jvmMain, iosMain) for `currentTimeMillis()` and `generateUuid()`
|
|
8. Write unit tests in `commonTest/`
|
|
9. Validate: `./gradlew :shared:compileKotlinJvm && ./gradlew :shared:jvmTest`
|
|
|
|
### Chunk 2B: Android WebView Host
|
|
|
|
**Goal:** Android WebView hosting, JS injection, dev mode, asset bundling.
|
|
|
|
**Steps:**
|
|
1. Implement `androidMain/webview/AndroidWebViewHost.kt`
|
|
2. Implement `androidMain/webview/SelfVerificationActivity.kt`
|
|
3. Configure WebView security settings
|
|
4. Set up dev mode URL loading (`http://10.0.2.2:5173`)
|
|
5. Create Gradle task for copying Vite `dist/` into assets
|
|
6. Validate: `./gradlew :shared:compileDebugKotlinAndroid`
|
|
|
|
### Chunk 2C: Android Native Handlers
|
|
|
|
**Goal:** All Android bridge handlers.
|
|
|
|
**Steps (in priority order):**
|
|
1. `NfcBridgeHandler` — port from `RNPassportReaderModule.kt` (biggest effort)
|
|
2. `BiometricBridgeHandler` — BiometricPrompt wrapper
|
|
3. `SecureStorageBridgeHandler` — EncryptedSharedPreferences
|
|
4. `CryptoBridgeHandler` — Android Keystore signing
|
|
5. `DocumentsBridgeHandler` — JSON CRUD on encrypted storage
|
|
6. `LifecycleBridgeHandler` — Activity result delivery
|
|
7. `HapticBridgeHandler` — Vibration
|
|
8. `AnalyticsBridgeHandler` — Logging
|
|
9. `CameraMrzBridgeHandler` — ML Kit text recognition
|
|
10. Validate: compile + unit tests
|
|
|
|
### Chunk 2D: iOS WebView Host + cinterop
|
|
|
|
**Goal:** iOS WebView hosting, cinterop definitions.
|
|
|
|
**Steps:**
|
|
1. Create `.def` files for CoreNFC, LocalAuthentication, Security, Vision
|
|
2. Implement `iosMain/webview/IosWebViewHost.kt`
|
|
3. Configure WKWebView with WKScriptMessageHandler
|
|
4. Validate: `./gradlew :shared:compileKotlinIosArm64`
|
|
|
|
### Chunk 2E: iOS Native Handlers
|
|
|
|
**Goal:** All iOS bridge handlers.
|
|
|
|
**Steps:**
|
|
1. `BiometricBridgeHandler` — LAContext (simplest, good to start)
|
|
2. `SecureStorageBridgeHandler` — Keychain Services
|
|
3. `CryptoBridgeHandler` — SecKey signing
|
|
4. `HapticBridgeHandler` — UIImpactFeedbackGenerator
|
|
5. `AnalyticsBridgeHandler` — os_log or similar
|
|
6. `LifecycleBridgeHandler` — ViewController dismissal
|
|
7. `DocumentsBridgeHandler` — Encrypted file storage
|
|
8. `NfcBridgeHandler` — CoreNFC (most complex, may need Swift wrapper)
|
|
9. `CameraMrzBridgeHandler` — Vision framework
|
|
10. Validate: compile for iOS targets
|
|
|
|
### Chunk 2F: SDK Public API + Test App
|
|
|
|
**Goal:** Public API + test app on both platforms.
|
|
|
|
**Steps:**
|
|
1. Implement `commonMain/api/SelfSdk.kt` (expect) + actuals
|
|
2. Create `packages/kmp-test-app/` with Compose Multiplatform
|
|
3. Android test app: "Launch Verification" button → `SelfSdk.launch()`
|
|
4. iOS test app: same button via SwiftUI wrapping KMP framework
|
|
5. Test on emulator/simulator
|
|
6. Configure `maven-publish` for AAR output
|
|
7. Configure XCFramework output + SPM `Package.swift`
|
|
8. Validate: test app builds and launches on both platforms
|
|
|
|
---
|
|
|
|
## Key Reference Files
|
|
|
|
| File | What to Look At |
|
|
|------|----------------|
|
|
| `app/android/.../RNPassportReaderModule.kt` | Android NFC implementation to port (PACE, BAC, DG reading, chip auth, passive auth) |
|
|
| `app/android/.../PassportNFC.kt` | Additional NFC utilities (if exists) |
|
|
| `app/ios/PassportReader.swift` | iOS NFC flow reference (MRZ key, readPassport call, SOD extraction) |
|
|
| `packages/kmp-shell/shared/` | Previous KMP prototype (bridge protocol, handler pattern, MRZ utils — all reusable) |
|
|
| `packages/webview-bridge/src/types.ts` | Bridge protocol TypeScript types (must match Kotlin exactly) |
|
|
| `packages/mobile-sdk-alpha/src/types/public.ts` | Adapter interfaces (what the WebView expects the bridge to implement) |
|