Files
self/specs/SPEC-PROVING-CLIENT.md
Justin Hernandez 466fd5d8e7 update kmp specs (#1757)
* save new specs

* rename specs
2026-02-16 00:42:06 -08:00

65 KiB
Raw Blame History

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 (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)

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:

// 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.

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

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

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<CircuitType> {
        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.

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:

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:

// Port of prepareAadhaarRegisterData
// Uses different input structure: QR data, public keys list

Register — KYC:

// Port of generateKycRegisterInput
// Uses serializedApplicantInfo, signature, pubkey

DSC:

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:

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:

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<String> {
        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:

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:

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.

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

private suspend fun <T> 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.

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:<hash>"
     *
     * @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.

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.

class ProtocolDataStore(private val config: ProvingConfig) {
    private val client = HttpClient {
        install(ContentNegotiation) {
            json(Json { ignoreUnknownKeys = true })
        }
    }
    private val cache = mutableMapOf<String, ProtocolData>()

    /**
     * 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<List<String>> {
        // 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.

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:

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

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.

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

data class ProofResult(
    val success: Boolean,
    val circuitType: CircuitType,
    val uuid: String? = null,
    val claims: Map<String, String>? = null,
)

CircuitType.kt

enum class CircuitType {
    DSC, REGISTER, DISCLOSE
}

DocumentCategory.kt

enum class DocumentCategory(val apiValue: String) {
    PASSPORT("passport"),
    ID_CARD("id_card"),
    AADHAAR("aadhaar"),
    KYC("kyc");
}

ProvingRequest.kt

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<String> = emptyList(),
    val minimumAge: Int? = null,
) {
    val revealedAttributes: List<String> 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

enum class EndpointType {
    CELO, STAGING_CELO, HTTPS
}

IDDocument.kt (common model hierarchy)

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<String, String>,
) : IDDocument()

ProtocolData.kt

data class ProtocolData(
    val deployedCircuits: DeployedCircuits,
    val circuitsDnsMapping: CircuitsDnsMapping,
    val dscTree: String,                    // Serialized LeanIMT
    val cscaTree: List<List<String>>,       // 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<String> = emptyList(),
    @SerialName("REGISTER_ID") val registerId: List<String> = emptyList(),
    @SerialName("REGISTER_AADHAAR") val registerAadhaar: List<String> = emptyList(),
    @SerialName("REGISTER_KYC") val registerKyc: List<String> = emptyList(),
    @SerialName("DSC") val dsc: List<String> = emptyList(),
    @SerialName("DSC_ID") val dscId: List<String> = emptyList(),
    @SerialName("DISCLOSE") val disclose: List<String> = emptyList(),
    @SerialName("DISCLOSE_ID") val discloseId: List<String> = emptyList(),
    @SerialName("DISCLOSE_AADHAAR") val discloseAadhaar: List<String> = emptyList(),
    @SerialName("DISCLOSE_KYC") val discloseKyc: List<String> = emptyList(),
) {
    fun getCircuits(mappingKey: String): List<String> = 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<String, Map<String, String>>

@Serializable
data class OfacTrees(
    val nameAndDob: String,       // Serialized SMT
    val nameAndYob: String,       // Serialized SMT
    val passportNoAndNationality: String? = null,  // Passport only
)

typealias AlternativeCsca = Map<String, String>  // CSCA label → PEM

PayloadModels.kt

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:

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 — 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:

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