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

1738 lines
65 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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`.
```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<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`:
```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 <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`.
```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:<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`.
```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<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`.
```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<String, String>? = 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<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`
```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<String, String>,
) : IDDocument()
```
### `ProtocolData.kt`
```kotlin
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`
```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` |