# Native Proving Client — KMP `commonMain` Implementation Spec ## Overview Port the TypeScript proving machine (`packages/mobile-sdk-alpha/src/proving/provingMachine.ts`) to native Kotlin in `commonMain`, enabling headless proof generation without a WebView. This is the foundation for: 1. **MiniPay integration** — crypto wallet needs native SDK, no WebView for sensitive operations 2. **Browser extension** — adding `jsMain` or `wasmMain` targets later gives the same proving logic for free The proving client lives entirely in `commonMain` so it works on Android, iOS, and future JS/WASM targets. **Prerequisites**: [SPEC-KMP-SDK.md](./SPEC-KMP-SDK.md) (bridge protocol, common models). --- ## Architecture ### Current TypeScript Stack ``` provingMachine.ts (XState state machine + Zustand store) → Circuit input generators (generateTEEInputs*) → TEE WebSocket connection (openpassport_hello, attestation, submit) → ECDH key exchange + AES-256-GCM encryption → Socket.IO status polling (queued → processing → success/failure) → Protocol data fetching (trees, circuits, DNS mapping) → Document validation (commitment lookup, nullifier check, DSC-in-tree) ``` ### Target Kotlin Structure ``` packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/ proving/ ProvingClient.kt — Public API: prove(passportData, request) → ProofResult ProvingStateMachine.kt — State machine (sealed class states, coroutine-based) CircuitInputGenerator.kt — Generate TEE circuit inputs from passport data CircuitNameResolver.kt — Map document metadata to circuit names TeeConnection.kt — WebSocket client for TEE prover (JSON-RPC) TeeAttestation.kt — JWT attestation validation, PCR0 mapping PayloadEncryption.kt — ECDH P-256 + AES-256-GCM ProtocolDataStore.kt — Fetch/cache DSC trees, CSCA trees, circuits, commitment trees DocumentValidator.kt — Check registration, nullification, DSC-in-tree StatusListener.kt — Socket.IO status polling for proof completion models/ ProofResult.kt — Proof UUID, status, claims CircuitType.kt — dsc, register, disclose DocumentCategory.kt — passport, id_card, aadhaar, kyc TeeMessage.kt — WebSocket JSON-RPC message types ProtocolData.kt — Trees, circuits, DNS mapping models PayloadModels.kt — TEEPayload, TEEPayloadDisclose, EncryptedPayload ProvingState.kt — State machine states (sealed class) EndpointType.kt — celo, staging_celo, https ``` --- ## Module Dependencies ### `build.gradle.kts` additions (commonMain) ```kotlin commonMain.dependencies { // Existing implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) // NEW — HTTP client (KMP-compatible) implementation("io.ktor:ktor-client-core:3.0.3") implementation("io.ktor:ktor-client-content-negotiation:3.0.3") implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.3") // NEW — WebSocket support implementation("io.ktor:ktor-client-websockets:3.0.3") } val androidMain by getting { dependencies { // Ktor engine implementation("io.ktor:ktor-client-okhttp:3.0.3") // Crypto (BouncyCastle already present for NFC) implementation("org.bouncycastle:bcprov-jdk18on:1.78.1") } } val iosMain by getting { dependencies { // Ktor engine implementation("io.ktor:ktor-client-darwin:3.0.3") } } ``` ### Platform-specific Crypto | Operation | Android | iOS | |-----------|---------|-----| | ECDH P-256 | BouncyCastle `ECDHBasicAgreement` | `SecKeyCreateRandomKey` + `SecKeyCopyKeyExchangeResult` | | AES-256-GCM | `javax.crypto.Cipher` | `CCCryptorGCM` (CommonCrypto) | | SHA-256 | `java.security.MessageDigest` | `CC_SHA256` (CommonCrypto) | | RSA verify | `java.security.Signature` | `SecKeyVerifySignature` | | X.509 parse | `java.security.cert.CertificateFactory` | `SecCertificateCreateWithData` | | Random bytes | `java.security.SecureRandom` | `SecRandomCopyBytes` | Define `expect`/`actual` for these in a `crypto/` package: ```kotlin // commonMain expect object PlatformCrypto { fun generateEcdhKeyPair(): EcdhKeyPair fun deriveSharedSecret(privateKey: ByteArray, peerPublicKey: ByteArray): ByteArray fun encryptAesGcm(plaintext: ByteArray, key: ByteArray, iv: ByteArray): AesGcmResult fun sha256(data: ByteArray): ByteArray fun verifyRsaSha256(publicKey: ByteArray, data: ByteArray, signature: ByteArray): Boolean fun randomBytes(size: Int): ByteArray fun parseX509Certificate(pem: String): X509CertificateInfo } data class EcdhKeyPair(val publicKey: ByteArray, val privateKey: ByteArray) data class AesGcmResult(val ciphertext: ByteArray, val authTag: ByteArray) data class X509CertificateInfo( val subjectCN: String?, val issuerCN: String?, val publicKeyBytes: ByteArray, val signatureBytes: ByteArray, val encoded: ByteArray, ) ``` --- ## Detailed Component Specs ### 1. `ProvingClient.kt` — Public API The main entry point for headless proof generation. Replaces the need for a WebView. ```kotlin package xyz.self.sdk.proving import xyz.self.sdk.models.* /** * Native proving client — generates zero-knowledge proofs without a WebView. * * Usage: * val client = ProvingClient(config) * val result = client.prove(passportData, request) */ class ProvingClient( private val config: ProvingConfig, ) { private val protocolStore = ProtocolDataStore(config) private val stateMachine = ProvingStateMachine() /** * Run the full proving flow: fetch data → validate → connect TEE → prove → return result. * * @param document Parsed passport/ID document from NFC scan * @param request Verification request parameters (scope, disclosures, endpoint) * @param secret User's secret key (loaded from secure storage) * @param onStateChange Optional callback for UI progress updates * @return ProofResult on success * @throws ProvingException on failure */ suspend fun prove( document: IDDocument, request: ProvingRequest, secret: String, onStateChange: ((ProvingState) -> Unit)? = null, ): ProofResult { return stateMachine.run( document = document, request = request, secret = secret, protocolStore = protocolStore, config = config, onStateChange = onStateChange, ) } } data class ProvingConfig( val environment: Environment = Environment.PROD, val debug: Boolean = false, ) enum class Environment { PROD, STG; val apiBaseUrl: String get() = when (this) { PROD -> "https://api.self.xyz" STG -> "https://api.staging.self.xyz" } val wsRelayerUrl: String get() = when (this) { PROD -> "wss://websocket.self.xyz" STG -> "wss://websocket.staging.self.xyz" } } ``` ### 2. `ProvingStateMachine.kt` — State Machine Port of the XState machine. Uses Kotlin sealed classes instead of XState/Zustand. #### State Definitions ```kotlin sealed class ProvingState { data object Idle : ProvingState() data object ParsingDocument : ProvingState() // DSC circuit only data object FetchingData : ProvingState() data object ValidatingDocument : ProvingState() data class ConnectingTee(val attempt: Int = 1) : ProvingState() data object ReadyToProve : ProvingState() data object Proving : ProvingState() data object PostProving : ProvingState() data class Completed(val result: ProofResult) : ProvingState() data class Error(val code: String, val reason: String) : ProvingState() data class DocumentNotSupported(val details: String) : ProvingState() data class AlreadyRegistered(val csca: String?) : ProvingState() data class AccountRecoveryChoice(val nullifier: String) : ProvingState() data class PassportDataNotFound(val details: String) : ProvingState() } ``` #### State Machine Runner ```kotlin class ProvingStateMachine { private var currentState: ProvingState = ProvingState.Idle suspend fun run( document: IDDocument, request: ProvingRequest, secret: String, protocolStore: ProtocolDataStore, config: ProvingConfig, onStateChange: ((ProvingState) -> Unit)?, ): ProofResult { fun transition(state: ProvingState) { currentState = state onStateChange?.invoke(state) } try { // Step 1: Determine circuit type sequence val circuitSequence = determineCircuitSequence(document, request) for (circuitType in circuitSequence) { // Step 2: Fetch protocol data transition(ProvingState.FetchingData) val protocolData = protocolStore.fetchAll( environment = config.environment, documentCategory = document.documentCategory, dscSki = document.dscAuthorityKeyIdentifier, ) // Step 3: Validate document transition(ProvingState.ValidatingDocument) val validation = DocumentValidator.validate( document = document, secret = secret, circuitType = circuitType, protocolData = protocolData, environment = config.environment, ) when (validation) { is ValidationResult.Supported -> { /* continue */ } is ValidationResult.AlreadyRegistered -> { transition(ProvingState.AlreadyRegistered(validation.csca)) continue // Skip to next circuit in sequence } is ValidationResult.NotSupported -> { transition(ProvingState.DocumentNotSupported(validation.details)) throw ProvingException("DOCUMENT_NOT_SUPPORTED", validation.details) } is ValidationResult.NotRegistered -> { transition(ProvingState.PassportDataNotFound(validation.details)) throw ProvingException("NOT_REGISTERED", validation.details) } is ValidationResult.AccountRecovery -> { transition(ProvingState.AccountRecoveryChoice(validation.nullifier)) throw ProvingException("ACCOUNT_RECOVERY", "Document nullified, recovery needed") } } // Step 4: Connect to TEE transition(ProvingState.ConnectingTee()) val teeConnection = TeeConnection(config) val session = teeConnection.connect( circuitName = CircuitNameResolver.resolve(document, circuitType), wsUrl = protocolData.resolveWebSocketUrl(circuitType, document), onReconnect = { attempt -> transition(ProvingState.ConnectingTee(attempt)) }, ) // Step 5: Generate inputs + encrypt + submit transition(ProvingState.Proving) val inputs = CircuitInputGenerator.generate( document = document, secret = secret, circuitType = circuitType, protocolData = protocolData, request = request, ) val payload = PayloadBuilder.build(inputs, circuitType, document, request) val encrypted = PayloadEncryption.encrypt( payload = payload, sharedKey = session.sharedKey, ) val proofUuid = teeConnection.submitProof(session, encrypted) // Step 6: Wait for result via Socket.IO val status = StatusListener.awaitResult( uuid = proofUuid, environment = config.environment, ) // Step 7: Post-proving transition(ProvingState.PostProving) if (status.isSuccess) { if (circuitType == CircuitType.REGISTER) { // Mark document as registered (caller should persist this) } } else { throw ProvingException(status.errorCode ?: "PROVE_FAILED", status.reason ?: "Proof generation failed") } } val result = ProofResult(success = true, circuitType = circuitSequence.last()) transition(ProvingState.Completed(result)) return result } catch (e: ProvingException) { transition(ProvingState.Error(e.code, e.message ?: "Unknown error")) throw e } } /** * Determine the sequence of circuits to prove. * DSC flow: [DSC, REGISTER] * Register flow: [REGISTER] * Disclose flow: [DISCLOSE] */ private fun determineCircuitSequence(document: IDDocument, request: ProvingRequest): List { return when (request.circuitType) { CircuitType.DSC -> listOf(CircuitType.DSC, CircuitType.REGISTER) CircuitType.REGISTER -> listOf(CircuitType.REGISTER) CircuitType.DISCLOSE -> listOf(CircuitType.DISCLOSE) } } } ``` **Key difference from TypeScript**: The TS version uses XState actor + Zustand store with event-driven transitions. The Kotlin version uses a linear `suspend fun` with structured concurrency — simpler, easier to test, and natural for Kotlin coroutines. --- ### 3. `CircuitInputGenerator.kt` — Generate TEE Circuit Inputs Port of `generateTEEInputsRegister`, `generateTEEInputsDSC`, `generateTEEInputsDiscloseStateless`. ```kotlin object CircuitInputGenerator { /** * Generate circuit inputs based on document type and circuit type. * * @return Circuit inputs as a JsonElement tree (to be serialized and encrypted) */ suspend fun generate( document: IDDocument, secret: String, circuitType: CircuitType, protocolData: ProtocolData, request: ProvingRequest, ): CircuitInputs { return when (circuitType) { CircuitType.REGISTER -> generateRegisterInputs(document, secret, protocolData) CircuitType.DSC -> generateDscInputs(document, protocolData) CircuitType.DISCLOSE -> generateDiscloseInputs(document, secret, protocolData, request) } } } ``` #### TypeScript Functions → Kotlin Equivalents | TypeScript Function | Kotlin Equivalent | Location | |---|---|---| | `generateTEEInputsRegister(secret, passportData, dscTree, env)` | `CircuitInputGenerator.generateRegisterInputs()` | `CircuitInputGenerator.kt` | | `generateTEEInputsDSC(passportData, cscaTree, env)` | `CircuitInputGenerator.generateDscInputs()` | `CircuitInputGenerator.kt` | | `generateTEEInputsDiscloseStateless(secret, passportData, selfApp, getTree)` | `CircuitInputGenerator.generateDiscloseInputs()` | `CircuitInputGenerator.kt` | | `generateCircuitInputsRegister(secret, passportData, dscTree)` | Internal to `generateRegisterInputs` | Port from `common/src/utils/circuits/registerInputs.ts` | | `generateCircuitInputsDSC(passportData, cscaTree)` | Internal to `generateDscInputs` | Port from `common/src/utils/circuits/registerInputs.ts` | | `generateCircuitInputsVCandDisclose(...)` | Internal to `generateDiscloseInputs` | Port from `common/src/utils/circuits/registerInputs.ts` | | `getSelectorDg1(documentCategory, disclosures)` | `SelectorGenerator.getDg1Selector()` | New helper | | `generateCommitment(secret, attestationId, passportData)` | `CommitmentGenerator.generate()` | New helper | | `packBytesAndPoseidon(bytes)` | `PoseidonUtils.packBytesAndHash()` | New helper | | `formatMrz(mrz)` | `MrzFormatter.format()` | New helper | #### Document-Specific Input Generation **Register — Passport/ID Card:** ```kotlin private fun generateRegisterInputs(document: IDDocument, secret: String, protocolData: ProtocolData): CircuitInputs { val passportData = document as PassportData val dscTree = protocolData.dscTree // Port of generateCircuitInputsRegister from common/src/utils/circuits/registerInputs.ts val commitment = CommitmentGenerator.generate(secret, passportData.attestationId, passportData) // ... format MRZ, hash eContent, generate Merkle proofs // Return structured circuit inputs } ``` **Register — Aadhaar:** ```kotlin // Port of prepareAadhaarRegisterData // Uses different input structure: QR data, public keys list ``` **Register — KYC:** ```kotlin // Port of generateKycRegisterInput // Uses serializedApplicantInfo, signature, pubkey ``` **DSC:** ```kotlin private fun generateDscInputs(document: IDDocument, protocolData: ProtocolData): CircuitInputs { val passportData = document as PassportData val cscaTree = protocolData.cscaTree // Port of generateCircuitInputsDSC // 1. Extract DSC signature from passport // 2. Parse CSCA certificate // 3. Get CSCA public key // 4. Generate Merkle proof for CSCA in CSCA tree // 5. Format signature and keys for circuit } ``` **Disclose — Passport/ID Card:** ```kotlin private fun generateDiscloseInputs( document: IDDocument, secret: String, protocolData: ProtocolData, request: ProvingRequest, ): CircuitInputs { val passportData = document as PassportData // 1. Generate selector bits for disclosed attributes val selectorDg1 = SelectorGenerator.getDg1Selector(document.documentCategory, request.disclosures) // 2. Load OFAC sparse merkle trees val ofacTrees = protocolData.ofacTrees // 3. Load commitment tree (LeanIMT) val commitmentTree = protocolData.commitmentTree // 4. Port of generateCircuitInputsVCandDisclose // Inputs: secret, attestation_id, passportData, scope_hash, // selector_dg1, selector_older_than, tree, majority, // passportNoAndNationalitySMT, nameAndDobSMT, nameAndYobSMT, // selector_ofac, excludedCountries, userIdentifierHash } ``` #### Selector Bit Array Generation Port of `getSelectorDg1` — maps disclosure flags to MRZ byte positions: ```kotlin object SelectorGenerator { // Passport MRZ (88 bytes) private val passportPositions = mapOf( "issuing_state" to (2..4), "name" to (5..43), "passport_number" to (44..52), "nationality" to (54..56), "date_of_birth" to (57..62), "gender" to (64..64), "expiry_date" to (65..70), ) // ID Card MRZ (90 bytes) private val idCardPositions = mapOf( "issuing_state" to (2..4), "passport_number" to (5..13), "date_of_birth" to (30..35), "gender" to (37..37), "expiry_date" to (38..43), "nationality" to (45..47), "name" to (60..89), ) fun getDg1Selector(category: DocumentCategory, disclosures: Disclosures): List { val size = if (category == DocumentCategory.ID_CARD) 90 else 88 val positions = if (category == DocumentCategory.ID_CARD) idCardPositions else passportPositions val selector = MutableList(size) { "0" } disclosures.revealedAttributes.forEach { attr -> positions[attr]?.let { range -> for (i in range) selector[i] = "1" } } return selector } } ``` --- ### 4. `CircuitNameResolver.kt` — Map Document to Circuit Name Port of `getCircuitNameFromPassportData`: ```kotlin object CircuitNameResolver { /** * Resolve circuit name from document metadata. * * Examples: * register_sha256_sha256_sha256_ecdsa_secp256r1 * dsc_sha256_ecdsa_secp256r1 * vc_and_disclose * register_aadhaar */ fun resolve(document: IDDocument, circuitType: CircuitType): String { return when (document.documentCategory) { DocumentCategory.AADHAAR -> when (circuitType) { CircuitType.REGISTER -> "register_aadhaar" CircuitType.DISCLOSE -> "vc_and_disclose_aadhaar" CircuitType.DSC -> throw IllegalArgumentException("Aadhaar has no DSC circuit") } DocumentCategory.KYC -> when (circuitType) { CircuitType.REGISTER -> "register_kyc" CircuitType.DISCLOSE -> "vc_and_disclose_kyc" CircuitType.DSC -> throw IllegalArgumentException("KYC has no DSC circuit") } DocumentCategory.PASSPORT, DocumentCategory.ID_CARD -> { val metadata = (document as PassportData).passportMetadata ?: throw ProvingException("METADATA_MISSING", "Passport metadata required") when (circuitType) { CircuitType.REGISTER -> buildRegisterCircuitName(metadata, document.documentCategory) CircuitType.DSC -> buildDscCircuitName(metadata) CircuitType.DISCLOSE -> if (document.documentCategory == DocumentCategory.PASSPORT) "vc_and_disclose" else "vc_and_disclose_id" } } } } /** * Build register circuit name from passport metadata. * Format: register_{dgHash}_{eContentHash}_{signedAttrHash}_{sigAlg}_{curveOrExp}[_{saltLen}][_{bits}] */ private fun buildRegisterCircuitName(metadata: PassportMetadata, category: DocumentCategory): String { val prefix = if (category == DocumentCategory.ID_CARD) "register_id" else "register" val parts = mutableListOf( prefix, metadata.dg1HashFunction, metadata.eContentHashFunction, metadata.signedAttrHashFunction, metadata.signatureAlgorithm, metadata.curveOrExponent, ) metadata.saltLength?.let { parts.add(it) } metadata.signatureAlgorithmBits?.let { parts.add(it) } return parts.joinToString("_") } /** * Build DSC circuit name from passport metadata. * Format: dsc_{hash}_{sigAlg}_{curve} (ECDSA) * dsc_{hash}_{sigAlg}_{exp}_{bits} (RSA) * dsc_{hash}_{sigAlg}_{exp}_{saltLen}_{bits} (RSA-PSS) */ private fun buildDscCircuitName(metadata: PassportMetadata): String { // Similar construction logic val parts = mutableListOf("dsc", metadata.cscaHashFunction, metadata.cscaSignatureAlgorithm, metadata.cscaCurveOrExponent) metadata.cscaSaltLength?.let { parts.add(it) } metadata.cscaSignatureAlgorithmBits?.let { parts.add(it) } return parts.joinToString("_") } } ``` #### Mapping Key Resolution Port of `getMappingKey` — maps (circuitType, documentCategory) to protocol store keys: ```kotlin object MappingKeyResolver { fun resolve(circuitType: CircuitType, category: DocumentCategory): String = when (category) { DocumentCategory.PASSPORT -> when (circuitType) { CircuitType.REGISTER -> "REGISTER" CircuitType.DSC -> "DSC" CircuitType.DISCLOSE -> "DISCLOSE" } DocumentCategory.ID_CARD -> when (circuitType) { CircuitType.REGISTER -> "REGISTER_ID" CircuitType.DSC -> "DSC_ID" CircuitType.DISCLOSE -> "DISCLOSE_ID" } DocumentCategory.AADHAAR -> when (circuitType) { CircuitType.REGISTER -> "REGISTER_AADHAAR" CircuitType.DISCLOSE -> "DISCLOSE_AADHAAR" CircuitType.DSC -> throw IllegalArgumentException("Aadhaar has no DSC") } DocumentCategory.KYC -> when (circuitType) { CircuitType.REGISTER -> "REGISTER_KYC" CircuitType.DISCLOSE -> "DISCLOSE_KYC" CircuitType.DSC -> throw IllegalArgumentException("KYC has no DSC") } } } ``` --- ### 5. `TeeConnection.kt` — WebSocket Client for TEE Port of WebSocket handling from `provingMachine.ts`. ```kotlin class TeeConnection(private val config: ProvingConfig) { private var ws: WebSocketSession? = null private val client = HttpClient { install(WebSockets) } /** * Connect to TEE WebSocket, perform hello + attestation handshake. * * @return TeeSession with shared key and connection UUID */ suspend fun connect( circuitName: String, wsUrl: String, onReconnect: ((Int) -> Unit)? = null, ): TeeSession { val ecdhKeyPair = PlatformCrypto.generateEcdhKeyPair() val connectionUuid = generateUuid() return withRetry(maxAttempts = 3, onRetry = onReconnect) { val session = client.webSocketSession(wsUrl) ws = session // Step 1: Send hello val helloMessage = buildJsonObject { put("jsonrpc", "2.0") put("method", "openpassport_hello") put("id", 1) putJsonObject("params") { putJsonArray("user_pubkey") { ecdhKeyPair.publicKey.forEach { add(it.toInt()) } } put("uuid", connectionUuid) } } session.send(Frame.Text(Json.encodeToString(helloMessage))) // Step 2: Wait for attestation response val attestationFrame = session.incoming.receive() as Frame.Text val response = Json.parseToJsonElement(attestationFrame.readText()).jsonObject val result = response["result"]?.jsonObject ?: throw ProvingException("TEE_ERROR", "No result in attestation response") val attestationToken = result["attestation"]?.jsonPrimitive?.content ?: throw ProvingException("TEE_ERROR", "No attestation token") // Step 3: Validate attestation val attestation = TeeAttestation.validate(attestationToken, config.debug) // Step 4: Verify client pubkey matches if (!ecdhKeyPair.publicKey.contentEquals(attestation.userPubkey)) { throw ProvingException("TEE_ERROR", "User public key mismatch in attestation") } // Step 5: Check PCR0 mapping TeeAttestation.checkPcr0(attestation.imageHash, config.environment) // Step 6: Derive shared key via ECDH val sharedKey = PlatformCrypto.deriveSharedSecret( ecdhKeyPair.privateKey, attestation.serverPubkey, ) TeeSession( ws = session, sharedKey = sharedKey, connectionUuid = connectionUuid, ) } } /** * Submit encrypted proof request to TEE. * * @return UUID for status polling */ suspend fun submitProof(session: TeeSession, encrypted: EncryptedPayload): String { val submitMessage = buildJsonObject { put("jsonrpc", "2.0") put("method", "openpassport_submit_request") put("id", 2) putJsonObject("params") { put("uuid", session.connectionUuid) putJsonArray("nonce") { encrypted.nonce.forEach { add(it.toInt()) } } putJsonArray("cipher_text") { encrypted.ciphertext.forEach { add(it.toInt()) } } putJsonArray("auth_tag") { encrypted.authTag.forEach { add(it.toInt()) } } } } session.ws.send(Frame.Text(Json.encodeToString(submitMessage))) // Wait for ACK with status UUID val ackFrame = session.ws.incoming.receive() as Frame.Text val ackResponse = Json.parseToJsonElement(ackFrame.readText()).jsonObject val statusUuid = ackResponse["result"]?.jsonPrimitive?.content ?: throw ProvingException("TEE_ERROR", "No UUID in submit ACK") return statusUuid } fun close() { // Close WebSocket } } data class TeeSession( val ws: WebSocketSession, val sharedKey: ByteArray, val connectionUuid: String, ) ``` #### Retry Logic ```kotlin private suspend fun withRetry( maxAttempts: Int, onRetry: ((Int) -> Unit)?, block: suspend () -> T, ): T { var lastException: Exception? = null for (attempt in 1..maxAttempts) { try { return block() } catch (e: Exception) { lastException = e if (attempt < maxAttempts) { val backoffMs = minOf(1000L * (1 shl (attempt - 1)), 10_000L) delay(backoffMs) onRetry?.invoke(attempt + 1) } } } throw lastException ?: ProvingException("TEE_CONNECT_FAILED", "Max reconnect attempts reached") } ``` --- ### 6. `TeeAttestation.kt` — Attestation Validation Port of `validatePKIToken` and `checkPCR0Mapping` from `common/src/utils/attest.ts`. ```kotlin object TeeAttestation { /** * Validate TEE attestation JWT token. * * JWT structure: header.payload.signature (RS256) * Header x5c: [leaf, intermediate, root] certificate chain * Payload eat_nonce: [userPubkey, serverPubkey] (base64) * Payload submods.container.image_digest: "sha256:" * * @param attestationToken JWT string * @param isDev If true, skip debug status check * @return Validated attestation data */ fun validate(attestationToken: String, isDev: Boolean): AttestationResult { val (headerB64, payloadB64, signatureB64) = attestationToken.split(".") // 1. Parse header, extract x5c certificate chain val header = Json.parseToJsonElement(base64UrlDecode(headerB64).decodeToString()).jsonObject val x5c = header["x5c"]?.jsonArray?.map { it.jsonPrimitive.content } ?: throw ProvingException("ATTESTATION_ERROR", "No x5c in header") require(x5c.size == 3) { "Expected 3 certificates in x5c chain" } // 2. Parse certificates: leaf, intermediate, root val certs = x5c.map { PlatformCrypto.parseX509Certificate(pemFromBase64(it)) } // 3. Verify root matches stored GCP root certificate verifyRootCertificate(certs[2]) // 4. Verify certificate chain signatures verifyCertificateChain(certs) // 5. Verify JWT signature (RS256) using leaf certificate val signingInput = "$headerB64.$payloadB64".encodeToByteArray() val signature = base64UrlDecode(signatureB64) require(PlatformCrypto.verifyRsaSha256(certs[0].publicKeyBytes, signingInput, signature)) { "JWT signature verification failed" } // 6. Parse payload val payload = Json.parseToJsonElement(base64UrlDecode(payloadB64).decodeToString()).jsonObject // 7. Check debug status (skip in dev mode) if (!isDev) { val dbgstat = payload["dbgstat"]?.jsonPrimitive?.content require(dbgstat == "disabled-since-boot") { "Debug mode is enabled on TEE" } } // 8. Extract keys and image hash val eatNonce = payload["eat_nonce"]?.jsonArray ?: throw ProvingException("ATTESTATION_ERROR", "No eat_nonce in payload") val userPubkey = base64Decode(eatNonce[0].jsonPrimitive.content) val serverPubkey = base64Decode(eatNonce[1].jsonPrimitive.content) val imageDigest = payload["submods"]?.jsonObject ?.get("container")?.jsonObject ?.get("image_digest")?.jsonPrimitive?.content ?: throw ProvingException("ATTESTATION_ERROR", "No image_digest") val imageHash = imageDigest.removePrefix("sha256:") return AttestationResult( userPubkey = userPubkey, serverPubkey = serverPubkey, imageHash = imageHash, verified = true, ) } /** * Check PCR0 hash against on-chain PCR0Manager contract. */ suspend fun checkPcr0(imageHashHex: String, environment: Environment) { require(imageHashHex.length == 64) { "Invalid PCR0 hash length: ${imageHashHex.length}" } // Query PCR0Manager contract on Celo via JSON-RPC val rpcUrl = "https://forno.celo.org" // Celo mainnet RPC val pcr0ManagerAddress = PCR0_MANAGER_ADDRESS val paddedHash = imageHashHex.padStart(96, '0') // Build eth_call to isPCR0Set(bytes) val client = HttpClient() val response = client.post(rpcUrl) { contentType(ContentType.Application.Json) setBody(buildJsonObject { put("jsonrpc", "2.0") put("method", "eth_call") put("id", 1) putJsonObject("params") { // ... ABI-encoded call to isPCR0Set } }) } // Parse response, verify returns true } private const val PCR0_MANAGER_ADDRESS = "0x..." // TODO: Extract from TypeScript constants /** * Stored GCP Confidential Computing root certificate (PEM). * Used to verify the TEE attestation certificate chain. */ private val GCP_ROOT_CERT = """ -----BEGIN CERTIFICATE----- ... -----END CERTIFICATE----- """.trimIndent() // TODO: Extract from common/src/utils/attest.ts } data class AttestationResult( val userPubkey: ByteArray, val serverPubkey: ByteArray, val imageHash: String, val verified: Boolean, ) ``` --- ### 7. `PayloadEncryption.kt` — ECDH + AES-256-GCM Port of encryption logic from `common/src/utils/proving.ts`. ```kotlin object PayloadEncryption { /** * Encrypt payload using AES-256-GCM with the ECDH-derived shared key. * * @param payload JSON string to encrypt * @param sharedKey 32-byte ECDH-derived shared key * @return Encrypted payload with nonce, ciphertext, and auth tag */ fun encrypt(payload: String, sharedKey: ByteArray): EncryptedPayload { require(sharedKey.size == 32) { "Shared key must be 32 bytes" } val iv = PlatformCrypto.randomBytes(12) // 12-byte random nonce val result = PlatformCrypto.encryptAesGcm( plaintext = payload.encodeToByteArray(), key = sharedKey, iv = iv, ) return EncryptedPayload( nonce = iv, ciphertext = result.ciphertext, authTag = result.authTag, ) } } ``` --- ### 8. `ProtocolDataStore.kt` — Fetch/Cache Protocol Data Port of protocol store fetching from `packages/mobile-sdk-alpha/src/stores/protocolStore.ts`. ```kotlin class ProtocolDataStore(private val config: ProvingConfig) { private val client = HttpClient { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } } private val cache = mutableMapOf() /** * Fetch all protocol data needed for proving. * Fetches in parallel: DSC tree, CSCA tree, commitment tree, OFAC trees, * deployed circuits, circuits DNS mapping, alternative CSCA. * * Results are cached by (environment, documentCategory) key. */ suspend fun fetchAll( environment: Environment, documentCategory: DocumentCategory, dscSki: String? = null, ): ProtocolData { val cacheKey = "${environment.name}:${documentCategory.name}" cache[cacheKey]?.let { return it } val baseUrl = environment.apiBaseUrl // Parallel fetch all data return coroutineScope { val deployedCircuits = async { fetchDeployedCircuits(baseUrl, documentCategory) } val circuitsDnsMapping = async { fetchCircuitsDnsMapping(baseUrl) } val dscTree = async { fetchDscTree(baseUrl, documentCategory) } val cscaTree = async { fetchCscaTree(baseUrl, documentCategory) } val commitmentTree = async { fetchCommitmentTree(baseUrl, documentCategory) } val ofacTrees = async { fetchOfacTrees(baseUrl, documentCategory) } val alternativeCsca = async { if (dscSki != null) fetchAlternativeCsca(baseUrl, documentCategory, dscSki) else null } ProtocolData( deployedCircuits = deployedCircuits.await(), circuitsDnsMapping = circuitsDnsMapping.await(), dscTree = dscTree.await(), cscaTree = cscaTree.await(), commitmentTree = commitmentTree.await(), ofacTrees = ofacTrees.await(), alternativeCsca = alternativeCsca.await(), ).also { cache[cacheKey] = it } } } // --- Individual fetchers --- private suspend fun fetchDeployedCircuits(baseUrl: String, category: DocumentCategory): DeployedCircuits { // GET {baseUrl}/deployed-circuits val response = client.get("$baseUrl/deployed-circuits") return response.body() } private suspend fun fetchCircuitsDnsMapping(baseUrl: String): CircuitsDnsMapping { // GET {baseUrl}/circuit-dns-mapping-gcp val response = client.get("$baseUrl/circuit-dns-mapping-gcp") return response.body() } private suspend fun fetchDscTree(baseUrl: String, category: DocumentCategory): String { // GET {baseUrl}/dsc-tree — returns serialized LeanIMT val response = client.get("$baseUrl/dsc-tree") { parameter("category", category.apiValue) } return response.body() } private suspend fun fetchCscaTree(baseUrl: String, category: DocumentCategory): List> { // GET {baseUrl}/csca-tree — returns 2D array for Merkle tree val response = client.get("$baseUrl/csca-tree") { parameter("category", category.apiValue) } return response.body() } private suspend fun fetchCommitmentTree(baseUrl: String, category: DocumentCategory): String { // GET {baseUrl}/identity-tree — returns serialized LeanIMT val response = client.get("$baseUrl/identity-tree") { parameter("category", category.apiValue) } return response.body() } private suspend fun fetchOfacTrees(baseUrl: String, category: DocumentCategory): OfacTrees { // GET {baseUrl}/ofac-trees val response = client.get("$baseUrl/ofac-trees") { parameter("category", category.apiValue) } return response.body() } private suspend fun fetchAlternativeCsca(baseUrl: String, category: DocumentCategory, ski: String): AlternativeCsca { // GET {baseUrl}/alternative-csca val response = client.get("$baseUrl/alternative-csca") { parameter("category", category.apiValue) parameter("ski", ski) } return response.body() } } ``` #### TypeScript Fetch Functions → Kotlin Equivalents | TypeScript | Kotlin | API Endpoint | |---|---|---| | `fetch_deployed_circuits(env)` | `fetchDeployedCircuits()` | `GET /deployed-circuits` | | `fetch_circuits_dns_mapping(env)` | `fetchCircuitsDnsMapping()` | `GET /circuit-dns-mapping-gcp` | | `fetch_dsc_tree(env)` | `fetchDscTree()` | `GET /dsc-tree` | | `fetch_csca_tree(env)` | `fetchCscaTree()` | `GET /csca-tree` | | `fetch_identity_tree(env)` | `fetchCommitmentTree()` | `GET /identity-tree` | | `fetch_ofac_trees(env)` | `fetchOfacTrees()` | `GET /ofac-trees` | | `fetch_alternative_csca(env, ski)` | `fetchAlternativeCsca()` | `GET /alternative-csca` | --- ### 9. `DocumentValidator.kt` — Document Validation Port of validation logic from `common/src/utils/passports/validate.ts`. ```kotlin object DocumentValidator { /** * Validate document eligibility for the given circuit type. * * Checks performed: * - Document metadata exists and CSCA was found during parsing * - Register + DSC circuits are deployed for this document's crypto params * - For disclose: user is registered (commitment exists in tree) * - For register: user is NOT already registered, document NOT nullified * - For register: DSC is in the DSC tree */ suspend fun validate( document: IDDocument, secret: String, circuitType: CircuitType, protocolData: ProtocolData, environment: Environment, ): ValidationResult { // Step 1: Check document supported (metadata, CSCA, circuits deployed) val supportStatus = checkDocumentSupported(document, protocolData.deployedCircuits) if (supportStatus != SupportStatus.SUPPORTED) { return ValidationResult.NotSupported(supportStatus.name) } // Step 2: Circuit-type-specific validation return when (circuitType) { CircuitType.DISCLOSE -> validateForDisclose(document, secret, protocolData) CircuitType.REGISTER -> validateForRegister(document, secret, protocolData, environment) CircuitType.DSC -> ValidationResult.Supported // DSC has no extra validation } } } ``` #### TypeScript Functions → Kotlin Equivalents | TypeScript Function | Kotlin Equivalent | Purpose | |---|---|---| | `checkDocumentSupported(passportData, opts)` | `DocumentValidator.checkDocumentSupported()` | Check metadata, CSCA, deployed circuits | | `isUserRegistered(documentData, secret, getCommitmentTree)` | `DocumentValidator.isUserRegistered()` | Look up commitment in LeanIMT | | `isUserRegisteredWithAlternativeCSCA(passportData, secret, opts)` | `DocumentValidator.isUserRegisteredWithAlternativeCsca()` | Try each alternative CSCA commitment | | `isDocumentNullified(passportData)` | `DocumentValidator.isDocumentNullified()` | POST to `/is-nullifier-onchain-with-attestation-id` | | `checkIfPassportDscIsInTree(passportData, dscTree)` | `DocumentValidator.isDscInTree()` | Look up DSC leaf in LeanIMT | #### Commitment Generation Port of `generateCommitment` from `common/src/utils/passports/passport.ts`: ```kotlin object CommitmentGenerator { /** * Generate a commitment hash for a passport/ID document. * commitment = poseidon5(secret, attestation_id, dg1_packed_hash, eContent_packed_hash, dsc_hash) * * @param secret User's secret key * @param attestationId "1" for passport, "2" for ID card * @param passportData Parsed passport data with MRZ, eContent, parsed certificates * @return Commitment as string representation of BigInt */ fun generate(secret: String, attestationId: String, passportData: PassportData): String { // 1. Pack MRZ bytes and hash with Poseidon val mrzBytes = MrzFormatter.format(passportData.mrz) val dg1PackedHash = PoseidonUtils.packBytesAndHash(mrzBytes) // 2. Hash eContent, then pack and hash with Poseidon val eContentShaBytes = PlatformCrypto.sha256(passportData.eContent) // Or sha384/sha512 based on metadata val eContentPackedHash = PoseidonUtils.packBytesAndHash(eContentShaBytes.map { it.toInt() and 0xff }) // 3. Get DSC tree leaf hash val dscHash = getLeafDscTree(passportData.dscParsed, passportData.cscaParsed) // 4. Poseidon-5 hash return poseidon5(listOf( secret.toBigInteger(), attestationId.toBigInteger(), dg1PackedHash, eContentPackedHash, dscHash, )).toString() } } ``` #### Constants ```kotlin object AttestationId { const val PASSPORT = "1" const val ID_CARD = "2" const val AADHAAR = "3" const val KYC = "4" } ``` --- ### 10. `StatusListener.kt` — Socket.IO Status Polling Port of `_startSocketIOStatusListener` from `provingMachine.ts`. **Note**: Ktor doesn't have native Socket.IO support. Use Ktor WebSocket with manual Socket.IO protocol handling, or use a KMP Socket.IO client library. ```kotlin object StatusListener { /** * Connect to Socket.IO relayer and wait for proof status. * * Status codes: * 3, 5 = Failure * 4 = Success * Other = In progress (keep listening) * * @param uuid Proof UUID returned by TEE submit * @param environment Determines which relayer URL to use * @return Final status (success or failure) */ suspend fun awaitResult( uuid: String, environment: Environment, timeout: Duration = 5.minutes, ): ProofStatus { val wsUrl = environment.wsRelayerUrl return withTimeout(timeout) { // Connect via WebSocket (Socket.IO over WS) val client = HttpClient { install(WebSockets) } val session = client.webSocketSession("$wsUrl/socket.io/?transport=websocket") try { // Socket.IO handshake // ... send "40" (connect to default namespace) // ... wait for "40" ack // Subscribe to UUID val subscribeMsg = """42["subscribe","$uuid"]""" session.send(Frame.Text(subscribeMsg)) // Listen for status messages for (frame in session.incoming) { if (frame is Frame.Text) { val text = frame.readText() val status = parseSocketIoStatus(text) ?: continue when (status.code) { 3, 5 -> return@withTimeout ProofStatus( isSuccess = false, errorCode = status.errorCode, reason = status.reason, ) 4 -> return@withTimeout ProofStatus(isSuccess = true) // Other status codes = in progress, keep listening } } } throw ProvingException("TIMEOUT", "Socket.IO connection closed without final status") } finally { session.close() } } } private fun parseSocketIoStatus(message: String): StatusMessage? { // Socket.IO message format: "42[\"status\",{...}]" if (!message.startsWith("42")) return null val jsonPart = message.substring(2) val array = Json.parseToJsonElement(jsonPart).jsonArray if (array[0].jsonPrimitive.content != "status") return null val data = array[1].jsonObject return StatusMessage( code = data["status"]?.jsonPrimitive?.int ?: return null, errorCode = data["error_code"]?.jsonPrimitive?.content, reason = data["reason"]?.jsonPrimitive?.content, ) } } data class ProofStatus( val isSuccess: Boolean, val errorCode: String? = null, val reason: String? = null, ) data class StatusMessage( val code: Int, val errorCode: String?, val reason: String?, ) ``` --- ## Data Models ### `ProofResult.kt` ```kotlin data class ProofResult( val success: Boolean, val circuitType: CircuitType, val uuid: String? = null, val claims: Map? = null, ) ``` ### `CircuitType.kt` ```kotlin enum class CircuitType { DSC, REGISTER, DISCLOSE } ``` ### `DocumentCategory.kt` ```kotlin enum class DocumentCategory(val apiValue: String) { PASSPORT("passport"), ID_CARD("id_card"), AADHAAR("aadhaar"), KYC("kyc"); } ``` ### `ProvingRequest.kt` ```kotlin data class ProvingRequest( val circuitType: CircuitType, val disclosures: Disclosures = Disclosures(), val scope: String? = null, val endpoint: String? = null, val endpointType: EndpointType = EndpointType.CELO, val chainId: Int? = null, val userId: String? = null, val userDefinedData: String = "", val selfDefinedData: String = "", val version: Int = 1, ) data class Disclosures( val name: Boolean = false, val dateOfBirth: Boolean = false, val gender: Boolean = false, val passportNumber: Boolean = false, val issuingState: Boolean = false, val nationality: Boolean = false, val expiryDate: Boolean = false, val ofac: Boolean = false, val excludedCountries: List = emptyList(), val minimumAge: Int? = null, ) { val revealedAttributes: List get() = buildList { if (name) add("name") if (dateOfBirth) add("date_of_birth") if (gender) add("gender") if (passportNumber) add("passport_number") if (issuingState) add("issuing_state") if (nationality) add("nationality") if (expiryDate) add("expiry_date") } } ``` ### `EndpointType.kt` ```kotlin enum class EndpointType { CELO, STAGING_CELO, HTTPS } ``` ### `IDDocument.kt` (common model hierarchy) ```kotlin sealed class IDDocument { abstract val documentCategory: DocumentCategory abstract val mock: Boolean abstract val dscAuthorityKeyIdentifier: String? } data class PassportData( override val documentCategory: DocumentCategory, // PASSPORT or ID_CARD override val mock: Boolean, override val dscAuthorityKeyIdentifier: String?, val mrz: String, // 88-char MRZ string val eContent: ByteArray, val dsc: String, // DSC certificate PEM val passportMetadata: PassportMetadata?, val dscParsed: ParsedCertificate?, val cscaParsed: ParsedCertificate?, ) : IDDocument() data class PassportMetadata( val countryCode: String, val cscaFound: Boolean, val dg1HashFunction: String, // "sha256", "sha384", "sha512" val eContentHashFunction: String, val signedAttrHashFunction: String, val signatureAlgorithm: String, // "ecdsa", "rsa", "rsapss" val curveOrExponent: String, // "secp256r1", "65537", etc. val saltLength: String? = null, val signatureAlgorithmBits: String? = null, val cscaHashFunction: String? = null, val cscaSignatureAlgorithm: String? = null, val cscaCurveOrExponent: String? = null, val cscaSaltLength: String? = null, val cscaSignatureAlgorithmBits: String? = null, ) data class AadhaarData( override val documentCategory: DocumentCategory = DocumentCategory.AADHAAR, override val mock: Boolean, override val dscAuthorityKeyIdentifier: String? = null, val qrData: String, val extractedFields: AadhaarFields, val publicKey: String, ) : IDDocument() data class KycData( override val documentCategory: DocumentCategory = DocumentCategory.KYC, override val mock: Boolean, override val dscAuthorityKeyIdentifier: String? = null, val serializedApplicantInfo: String, val signature: String, val pubkey: Pair, ) : IDDocument() ``` ### `ProtocolData.kt` ```kotlin data class ProtocolData( val deployedCircuits: DeployedCircuits, val circuitsDnsMapping: CircuitsDnsMapping, val dscTree: String, // Serialized LeanIMT val cscaTree: List>, // 2D Merkle tree val commitmentTree: String, // Serialized LeanIMT val ofacTrees: OfacTrees, val alternativeCsca: AlternativeCsca?, ) { /** * Resolve WebSocket URL for a specific circuit from DNS mapping. */ fun resolveWebSocketUrl(circuitType: CircuitType, document: IDDocument): String { val mappingKey = MappingKeyResolver.resolve(circuitType, document.documentCategory) val circuitName = CircuitNameResolver.resolve(document, circuitType) return circuitsDnsMapping.mapping[mappingKey]?.get(circuitName) ?: throw ProvingException("CIRCUIT_NOT_FOUND", "No WebSocket URL for $mappingKey/$circuitName") } } @Serializable data class DeployedCircuits( @SerialName("REGISTER") val register: List = emptyList(), @SerialName("REGISTER_ID") val registerId: List = emptyList(), @SerialName("REGISTER_AADHAAR") val registerAadhaar: List = emptyList(), @SerialName("REGISTER_KYC") val registerKyc: List = emptyList(), @SerialName("DSC") val dsc: List = emptyList(), @SerialName("DSC_ID") val dscId: List = emptyList(), @SerialName("DISCLOSE") val disclose: List = emptyList(), @SerialName("DISCLOSE_ID") val discloseId: List = emptyList(), @SerialName("DISCLOSE_AADHAAR") val discloseAadhaar: List = emptyList(), @SerialName("DISCLOSE_KYC") val discloseKyc: List = emptyList(), ) { fun getCircuits(mappingKey: String): List = when (mappingKey) { "REGISTER" -> register "REGISTER_ID" -> registerId "REGISTER_AADHAAR" -> registerAadhaar "REGISTER_KYC" -> registerKyc "DSC" -> dsc "DSC_ID" -> dscId "DISCLOSE" -> disclose "DISCLOSE_ID" -> discloseId "DISCLOSE_AADHAAR" -> discloseAadhaar "DISCLOSE_KYC" -> discloseKyc else -> emptyList() } } typealias CircuitsDnsMapping = Map> @Serializable data class OfacTrees( val nameAndDob: String, // Serialized SMT val nameAndYob: String, // Serialized SMT val passportNoAndNationality: String? = null, // Passport only ) typealias AlternativeCsca = Map // CSCA label → PEM ``` ### `PayloadModels.kt` ```kotlin data class EncryptedPayload( val nonce: ByteArray, // 12 bytes val ciphertext: ByteArray, val authTag: ByteArray, // 16 bytes ) data class CircuitInputs( val inputs: JsonElement, val circuitName: String, val endpointType: EndpointType, val endpoint: String, ) ``` ### `PayloadBuilder.kt` Port of `getPayload` from `common/src/utils/proving.ts`: ```kotlin object PayloadBuilder { /** * Build the plaintext payload to encrypt and send to TEE. * Handles BigInt serialization via custom replacer. */ fun build( inputs: CircuitInputs, circuitType: CircuitType, document: IDDocument, request: ProvingRequest, ): String { val payloadJson = buildJsonObject { put("type", resolvePayloadType(circuitType, document.documentCategory)) put("onchain", inputs.endpointType == EndpointType.CELO) put("endpointType", inputs.endpointType.name.lowercase()) putJsonObject("circuit") { put("name", inputs.circuitName) put("inputs", Json.encodeToString(inputs.inputs)) // Nested JSON string } // Disclose-specific fields if (circuitType == CircuitType.DISCLOSE) { put("endpoint", inputs.endpoint) put("version", request.version) put("userDefinedData", request.userDefinedData) put("selfDefinedData", request.selfDefinedData) } } return Json.encodeToString(payloadJson) } private fun resolvePayloadType(circuitType: CircuitType, category: DocumentCategory): String { return when (circuitType) { CircuitType.REGISTER -> when (category) { DocumentCategory.PASSPORT -> "register" DocumentCategory.ID_CARD -> "register_id" DocumentCategory.AADHAAR -> "register_aadhaar" DocumentCategory.KYC -> "register_kyc" } CircuitType.DSC -> when (category) { DocumentCategory.PASSPORT -> "dsc" DocumentCategory.ID_CARD -> "dsc_id" else -> throw IllegalArgumentException("DSC not supported for $category") } CircuitType.DISCLOSE -> when (category) { DocumentCategory.PASSPORT -> "disclose" DocumentCategory.ID_CARD -> "disclose_id" DocumentCategory.AADHAAR -> "disclose_aadhaar" DocumentCategory.KYC -> "disclose_kyc" } } } } ``` --- ## Chunking Guide ### Chunk 4A: Models + ProtocolDataStore (start here) **Goal**: Define all data models and implement HTTP fetching. **Steps**: 1. Create all model files in `proving/models/` 2. Implement `ProtocolDataStore.kt` with all 7 fetchers 3. Add Ktor HTTP client dependencies to `build.gradle.kts` 4. Write unit tests for model serialization 5. Validate: API calls return correct data, models deserialize ### Chunk 4B: Platform Crypto **Goal**: Implement `expect`/`actual` for all cryptographic operations. **Steps**: 1. Define `PlatformCrypto` expect in `commonMain` 2. Implement `androidMain` actual (BouncyCastle + JCE) 3. Implement `iosMain` actual (Security framework via Swift provider, or direct CommonCrypto cinterop) 4. Test: ECDH key exchange, AES-GCM encrypt/decrypt roundtrip, SHA-256, RSA verify 5. Validate: Same inputs produce same outputs on both platforms **Note**: For iOS, crypto can use the same Swift provider pattern from [SPEC-IOS-HANDLERS.md](./SPEC-IOS-HANDLERS.md) — add a `PlatformCryptoProvider` interface and Swift implementation. Or if CommonCrypto cinterop works (it's simpler than UIKit cinterop), use it directly. ### Chunk 4C: TEE Connection + Attestation **Goal**: WebSocket connection, attestation validation, ECDH handshake. **Steps**: 1. Implement `TeeConnection.kt` — WebSocket connect, hello, receive attestation 2. Implement `TeeAttestation.kt` — JWT validation, certificate chain, PCR0 check 3. Implement `PayloadEncryption.kt` — AES-GCM encrypt with shared key 4. Add Ktor WebSocket dependencies 5. Test: Connect to TEE endpoint, validate attestation, derive shared key ### Chunk 4D: Document Validation + Circuit Names **Goal**: All validation logic and circuit name resolution. **Steps**: 1. Implement `DocumentValidator.kt` with all validation functions 2. Implement `CircuitNameResolver.kt` and `MappingKeyResolver` 3. Implement `CommitmentGenerator.kt` (requires Poseidon hash — see dependencies below) 4. Implement `SelectorGenerator.kt` for DG1 selector bits 5. Port LeanIMT tree operations (import, indexOf) for commitment/DSC lookup 6. Test: Validation returns correct results for each document type **Poseidon Hash Dependency**: The TypeScript uses `poseidon-lite` for Poseidon-2 and Poseidon-5. Options for Kotlin: - Port the Poseidon implementation to Kotlin (the algorithm is well-defined, ~200 lines) - Use a KMP-compatible Poseidon library if one exists - Call into JavaScript via embedded engine (last resort) ### Chunk 4E: Circuit Input Generation **Goal**: Port all circuit input generators. **Steps**: 1. Implement `CircuitInputGenerator.kt` — register, DSC, disclose for each document type 2. Port `generateCircuitInputsRegister` from `common/src/utils/circuits/registerInputs.ts` 3. Port `generateCircuitInputsDSC` 4. Port `generateCircuitInputsVCandDisclose` 5. Port Aadhaar and KYC-specific input generators 6. Test: Same inputs as TypeScript for known test vectors ### Chunk 4F: State Machine + Status Listener + Public API **Goal**: Wire everything together. **Steps**: 1. Implement `ProvingStateMachine.kt` — orchestrate the full flow 2. Implement `StatusListener.kt` — Socket.IO polling 3. Implement `ProvingClient.kt` — public API 4. Implement `PayloadBuilder.kt` — payload construction 5. Integration test: Full prove() flow against staging TEE 6. Validate: End-to-end proof generation works on both platforms --- ## Critical Implementation Notes ### BigInt Handling TypeScript uses native `BigInt` extensively for circuit inputs and Poseidon hashes. Kotlin options: - `java.math.BigInteger` on JVM/Android - Need a `commonMain` BigInt: Use `com.ionspin.kotlin.bignum:bignum` KMP library, or define expect/actual wrapping platform BigInteger ### Poseidon Hash Must produce identical outputs to `poseidon-lite` npm package. The Poseidon hash uses specific round constants and parameters for each width (2, 5). Port the constants from the npm package source. ### LeanIMT (Lean Incremental Merkle Tree) Port of `@openpassport/zk-kit-lean-imt`. Key operations needed: - `import(hashFn, serialized)` — deserialize tree from JSON - `indexOf(leaf)` — find leaf index in tree - `generateProof(index)` — generate inclusion proof (for circuit inputs) ### SMT (Sparse Merkle Tree) Port of `@openpassport/zk-kit-smt`. Used for OFAC checks. Key operations: - `import(serialized)` — deserialize - `createProof(key)` — inclusion/exclusion proof ### JSON Serialization with BigInt Circuit inputs contain BigInt values that must be serialized as strings (not numbers) in JSON. Use a custom serializer: ```kotlin fun bigIntReplacer(value: Any): Any = when (value) { is BigInteger -> value.toString() else -> value } ``` --- ## Testing Strategy ### Unit Tests (`commonTest/`) **Models & Serialization** (~10 tests): - All `@Serializable` models roundtrip through JSON - `DeployedCircuits` deserializes from real API response snapshot - `CircuitsDnsMapping` deserializes correctly - `ProvingRequest` defaults are correct - `Disclosures.revealedAttributes` computes correctly **Circuit Name Resolution** (~15 tests): - `CircuitNameResolver.resolve()` for each (documentCategory × circuitType) combination - Known passport metadata → expected circuit name string (e.g., `register_sha256_sha256_sha256_ecdsa_secp256r1`) - Known DSC metadata → expected DSC circuit name - Aadhaar/KYC → correct fixed names - Error: DSC for Aadhaar throws - `MappingKeyResolver.resolve()` for all 10 mapping key combinations **Payload Builder** (~8 tests): - Register payload has `onchain: true`, correct `type` field - Disclose payload includes `endpoint`, `version`, `userDefinedData`, `selfDefinedData` - Payload type strings correct for each (circuitType × documentCategory) - BigInt values serialized as strings (not numbers) **Document Validation** (~12 tests): - `checkDocumentSupported` returns correct status for: supported passport, missing metadata, missing CSCA, undeployed register circuit, undeployed DSC circuit - `isUserRegistered` returns true when commitment is in tree, false when not - `isDscInTree` returns true when DSC leaf is in tree, false when not - Aadhaar/KYC validation paths **TEE Attestation** (~8 tests): - `validate()` with known good attestation token → extracts correct pubkeys and image hash - `validate()` with tampered signature → throws - `validate()` with wrong root cert → throws - `validate()` with debug mode enabled + isDev=false → throws - `validate()` with debug mode enabled + isDev=true → succeeds **Payload Encryption** (~5 tests): - Encrypt with known key → decrypt with same key → matches plaintext - Nonce is 12 bytes, auth tag is 16 bytes - Different encryptions of same plaintext produce different ciphertexts (random IV) ### Integration Tests (staging environment) **Protocol Data Fetching**: - `fetchAll()` against staging API returns non-empty data for passport category - Deployed circuits list is non-empty - Circuits DNS mapping contains expected keys - Commitment tree deserializes and has positive size - Caching works: second `fetchAll()` returns same instance **TEE Connection**: - Connect to staging TEE WebSocket → receive attestation → validate → derive shared key - Submit encrypted payload → receive UUID ACK - Reconnection: close socket, verify reconnect succeeds within 3 attempts **Socket.IO Status Listener**: - Connect to staging relayer → subscribe to UUID → receive status messages - Timeout fires if no status received within limit **End-to-End** (requires mock passport): - `ProvingClient.prove()` with mock passport data → register circuit → success - `ProvingClient.prove()` with mock passport data → disclose circuit → success - State change callbacks fire in correct order - Error propagation: invalid document → `ProvingException` with correct code ### Cross-Platform Verification - Run `commonTest` on JVM and iOS: `./gradlew :shared:jvmTest :shared:iosSimulatorArm64Test` - Platform crypto (`PlatformCrypto`): Same ECDH shared secret from same key pairs on Android and iOS - Same AES-GCM encryption with fixed IV produces identical ciphertext on both platforms - Same inputs → same commitment hash on Android and iOS --- ## Dependencies - **SPEC-KMP-SDK.md**: Bridge protocol, common models (complete) - **SPEC-IOS-HANDLERS.md**: iOS crypto provider (Chunk 4B may need this for iOS actual) - **SPEC-MINIPAY-SAMPLE.md**: Depends on this spec's `ProvingClient` public API ## Key TypeScript Reference Files | TypeScript File | What to Port | Kotlin Target | |---|---|---| | `packages/mobile-sdk-alpha/src/proving/provingMachine.ts` | State machine, WebSocket handling, proof flow | `ProvingStateMachine.kt`, `TeeConnection.kt` | | `packages/mobile-sdk-alpha/src/proving/internal/statusHandlers.ts` | Socket.IO status parsing | `StatusListener.kt` | | `common/src/utils/proving.ts` | `getPayload`, `encryptAES256GCM`, `getWSDbRelayerUrl` | `PayloadBuilder.kt`, `PayloadEncryption.kt` | | `common/src/utils/attest.ts` | `validatePKIToken`, `checkPCR0Mapping` | `TeeAttestation.kt` | | `common/src/utils/circuits/registerInputs.ts` | `generateCircuitInputsRegister`, `generateCircuitInputsDSC`, `generateCircuitInputsVCandDisclose`, `getSelectorDg1` | `CircuitInputGenerator.kt`, `SelectorGenerator.kt` | | `common/src/utils/circuits/circuitsName.ts` | `getCircuitNameFromPassportData` | `CircuitNameResolver.kt` | | `common/src/utils/passports/validate.ts` | `checkDocumentSupported`, `isUserRegistered`, `isDocumentNullified`, `checkIfPassportDscIsInTree` | `DocumentValidator.kt` | | `common/src/utils/passports/passport.ts` | `generateCommitment`, `packBytesAndPoseidon`, `formatMrz` | `CommitmentGenerator.kt`, `PoseidonUtils.kt` | | `common/src/constants/constants.ts` | `PASSPORT_ATTESTATION_ID`, `ID_CARD_ATTESTATION_ID`, API URLs | `AttestationId` object, `Environment` enum | | `packages/mobile-sdk-alpha/src/stores/protocolStore.ts` | Protocol data fetching, tree caching | `ProtocolDataStore.kt` |