From 466fd5d8e76dda37b04fbf798e43fda15b509094 Mon Sep 17 00:00:00 2001 From: Justin Hernandez Date: Mon, 16 Feb 2026 00:42:06 -0800 Subject: [PATCH] update kmp specs (#1757) * save new specs * rename specs --- specs/SPEC-COMMON-LIB.md | 1532 +++++++++++++++ specs/SPEC-IOS-HANDLERS.md | 1129 +++++++++++ .../{SPEC-PERSON2-KMP.md => SPEC-KMP-SDK.md} | 15 + specs/SPEC-MINIPAY-SAMPLE.md | 623 ++++++ specs/{SPEC.md => SPEC-OVERVIEW.md} | 10 +- specs/SPEC-PROVING-CLIENT.md | 1737 +++++++++++++++++ ...{SPEC-PERSON1-UI.md => SPEC-WEBVIEW-UI.md} | 0 7 files changed, 5043 insertions(+), 3 deletions(-) create mode 100644 specs/SPEC-COMMON-LIB.md create mode 100644 specs/SPEC-IOS-HANDLERS.md rename specs/{SPEC-PERSON2-KMP.md => SPEC-KMP-SDK.md} (97%) create mode 100644 specs/SPEC-MINIPAY-SAMPLE.md rename specs/{SPEC.md => SPEC-OVERVIEW.md} (95%) create mode 100644 specs/SPEC-PROVING-CLIENT.md rename specs/{SPEC-PERSON1-UI.md => SPEC-WEBVIEW-UI.md} (100%) diff --git a/specs/SPEC-COMMON-LIB.md b/specs/SPEC-COMMON-LIB.md new file mode 100644 index 000000000..4280c5861 --- /dev/null +++ b/specs/SPEC-COMMON-LIB.md @@ -0,0 +1,1532 @@ +# Common KMP Library — Port `@selfxyz/common` Utilities to Pure Kotlin + +## Overview + +Port the core math, cryptographic hashing, tree operations, passport parsing, and certificate parsing from TypeScript (`common/src/utils/`) to pure Kotlin in `commonMain`. This library has **zero platform dependencies** — no `expect`/`actual`, no Android/iOS APIs. Everything is pure Kotlin math that compiles for JVM, iOS, JS, and WASM targets. + +This is the foundation layer that both [SPEC-PROVING-CLIENT.md](./SPEC-PROVING-CLIENT.md) and a future browser extension depend on. + +**Prerequisites**: None (this is the leaf dependency). + +--- + +## Why Pure `commonMain` + +Every function in this library is deterministic math: hash bytes, build trees, parse ASN.1, pack field elements. None of it touches platform APIs (no file system, no networking, no UI). By keeping it in `commonMain` as pure Kotlin: + +- Adding `jsMain` or `wasmMain` later for a browser extension costs zero porting effort for this layer +- Unit tests run on JVM (`commonTest`) with fast iteration +- Identical outputs are guaranteed across all platforms + +The only exception is SHA hashing (SHA-1, SHA-256, SHA-384, SHA-512) which could use platform implementations for performance, but a pure Kotlin implementation works fine and avoids any `expect`/`actual` complexity. + +--- + +## Module Structure + +``` +packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/common/ + hash/ + Poseidon.kt — Poseidon hash (1–16 input variants) + PoseidonConstants.kt — Round constants and mixing matrices (BN254) + FlexiblePoseidon.kt — Dynamic variant selection + chunked hashing + Sha.kt — SHA-1/224/256/384/512 (pure Kotlin or expect/actual) + ShaPad.kt — SHA padding for circuit inputs + math/ + BigIntField.kt — Field arithmetic over BN254 prime + BytePacking.kt — packBytes, splitToWords, hexToDecimal, num2Bits + trees/ + LeanIMT.kt — Lean Incremental Merkle Tree (import, indexOf, generateProof) + SparseMerkleTree.kt — Sparse Merkle Tree (import, add, createProof) + MerkleProof.kt — Proof data structures + LeafGenerators.kt — OFAC leaf functions (name, DOB, country, passport number) + TreeConstants.kt — Depth constants + passport/ + PassportDataParser.kt — initPassportDataParsing (metadata extraction) + MrzFormatter.kt — formatMrz (DER/TLV encoding) + CommitmentGenerator.kt — generateCommitment (Poseidon-5) + NullifierGenerator.kt — generateNullifier + DscLeaf.kt — getLeafDscTree (DSC + CSCA leaf hashing) + SelectorGenerator.kt — getSelectorDg1 (attribute → MRZ position mapping) + SignatureExtractor.kt — Extract r,s from DER-encoded ECDSA signatures + certificate/ + Asn1Parser.kt — Minimal ASN.1 DER parser (Tag-Length-Value) + X509CertificateParser.kt — parseCertificateSimple → CertificateData + OidResolver.kt — OID → algorithm/curve name mapping + CscaLookup.kt — getCSCAFromSKI (find issuer cert by SKI) + models/ + CertificateData.kt — Parsed certificate with pub key details + PassportMetadata.kt — Metadata extracted from passport data + FieldElement.kt — BigInt wrapper for BN254 field elements + constants/ + Constants.kt — Tree depths, max padded sizes, attestation IDs + SkiPem.kt — SKI → CSCA PEM mapping (prod + staging) +``` + +Test mirror: +``` +packages/kmp-sdk/shared/src/commonTest/kotlin/xyz/self/sdk/common/ + hash/ + PoseidonTest.kt — Known test vectors from poseidon-lite + FlexiblePoseidonTest.kt — packBytesAndPoseidon roundtrips + ShaPadTest.kt — Padding output verification + math/ + BytePackingTest.kt — packBytes, splitToWords test vectors + trees/ + LeanIMTTest.kt — Import, indexOf, generateProof + SparseMerkleTreeTest.kt — Add, createProof, membership/non-membership + LeafGeneratorsTest.kt — Known leaf values + passport/ + PassportDataParserTest.kt — Parse mock passports, verify metadata + CommitmentGeneratorTest.kt — Known commitment hashes + MrzFormatterTest.kt — TLV encoding verification + certificate/ + Asn1ParserTest.kt — Parse known DER structures + X509CertificateParserTest.kt — Parse real DSC/CSCA certificates +``` + +--- + +## Detailed Component Specs + +### 1. Poseidon Hash + +Port of `poseidon-lite` npm package. The Poseidon hash operates over the BN254 scalar field. + +#### Field Prime + +```kotlin +object BN254 { + val PRIME = "21888242871839275222246405745257275088548364400416034343698204186575808495617".toBigInteger() +} +``` + +#### Algorithm + +```kotlin +/** + * Poseidon hash function over BN254 field. + * + * @param inputs 1–16 field elements + * @return Single field element hash + */ +fun poseidon(inputs: List): BigInteger { + require(inputs.size in 1..16) { "Poseidon supports 1-16 inputs, got ${inputs.size}" } + + val t = inputs.size + 1 // State width = inputs + capacity + val nRoundsF = 8 // Full rounds (4 before + 4 after partial rounds) + val nRoundsP = PARTIAL_ROUNDS[inputs.size - 1] // Varies by input count + + // Initialize state: [0, input_0, input_1, ..., input_n] + val state = mutableListOf(BigInteger.ZERO) + state.addAll(inputs.map { it.mod(BN254.PRIME) }) + + val C = getRoundConstants(t) // Round constants for width t + val M = getMixingMatrix(t) // MDS mixing matrix for width t + + for (round in 0 until nRoundsF + nRoundsP) { + // Add round constants + for (i in 0 until t) { + state[i] = (state[i] + C[round * t + i]).mod(BN254.PRIME) + } + + // S-box: x^5 mod p + if (round < nRoundsF / 2 || round >= nRoundsF / 2 + nRoundsP) { + // Full round: apply S-box to all elements + for (i in 0 until t) { + state[i] = pow5(state[i]) + } + } else { + // Partial round: apply S-box only to first element + state[0] = pow5(state[0]) + } + + // Linear mixing: state = M * state + val newState = MutableList(t) { BigInteger.ZERO } + for (i in 0 until t) { + for (j in 0 until t) { + newState[i] = (newState[i] + M[i][j] * state[j]).mod(BN254.PRIME) + } + } + for (i in 0 until t) state[i] = newState[i] + } + + return state[0] // Output is first element +} + +private fun pow5(v: BigInteger): BigInteger { + val v2 = (v * v).mod(BN254.PRIME) + return (v * v2 * v2).mod(BN254.PRIME) +} +``` + +#### Round Constants + +Partial rounds per input count (from `poseidon-lite`): + +```kotlin +val PARTIAL_ROUNDS = intArrayOf( + 56, 57, 56, 60, 60, 63, 64, 63, 60, 66, 60, 65, 70, 60, 64, 68 +) +// Index 0 = poseidon1 (56 partial rounds) +// Index 1 = poseidon2 (57 partial rounds) +// ... +// Index 15 = poseidon16 (68 partial rounds) +``` + +The round constants (C) and mixing matrices (M) are large — ~50KB of BigInteger constants total. These are generated by the Grain LFSR: +``` +generate_parameters_grain.sage 1 0 254 {t} 8 {nRoundsP} 0x30644e72e131a029b85045b68181585d2833e84879b9709143e1f593f0000001 +``` + +**Implementation approach**: Store constants as base64-encoded strings in `PoseidonConstants.kt` (matching `poseidon-lite`'s format), decode lazily on first use. + +#### Convenience Functions + +```kotlin +fun poseidon2(a: BigInteger, b: BigInteger): BigInteger = poseidon(listOf(a, b)) +fun poseidon3(a: BigInteger, b: BigInteger, c: BigInteger): BigInteger = poseidon(listOf(a, b, c)) +fun poseidon5(inputs: List): BigInteger = poseidon(inputs) +// ... poseidon1 through poseidon16 +``` + +#### `flexiblePoseidon` and `packBytesAndPoseidon` + +```kotlin +/** + * Dynamically selects Poseidon variant based on input count. + */ +fun flexiblePoseidon(inputs: List): BigInteger { + require(inputs.size in 1..16) { "flexiblePoseidon supports 1-16 inputs" } + return poseidon(inputs) +} + +/** + * Pack byte array into field elements and hash with Poseidon. + * Bytes are chunked into 31-byte groups, each becoming a field element. + * If >16 chunks, uses chunked hashing (poseidon16 per group, then combine). + * + * Port of packBytesAndPoseidon from common/src/utils/hash.ts + */ +fun packBytesAndPoseidon(bytes: List): BigInteger { + val packed = packBytesArray(bytes) + return customHasher(packed) +} + +/** + * Chunked Poseidon hashing for large inputs. + * For ≤16 elements: direct flexiblePoseidon + * For >16 elements: chunk into groups of 16, hash each, then hash results + */ +fun customHasher(elements: List): BigInteger { + if (elements.size <= 16) { + return flexiblePoseidon(elements) + } + + val chunks = elements.chunked(16) + val chunkHashes = chunks.map { chunk -> + if (chunk.size == 16) poseidon(chunk) + else flexiblePoseidon(chunk) + } + + return if (chunkHashes.size <= 16) { + flexiblePoseidon(chunkHashes) + } else { + customHasher(chunkHashes) // Recursive for very large inputs + } +} +``` + +#### Testing + +```kotlin +class PoseidonTest { + @Test + fun `poseidon2 known vector`() { + // Generate test vectors by running the TypeScript: + // import { poseidon2 } from 'poseidon-lite' + // console.log(poseidon2([1n, 2n]).toString()) + val result = poseidon2(BigInteger.ONE, BigInteger.TWO) + assertEquals("7853200120776062878684798364095072458815029376092732009249414926327459813530".toBigInteger(), result) + } + + @Test + fun `poseidon5 known vector`() { + val result = poseidon5(listOf(1, 2, 3, 4, 5).map { it.toBigInteger() }) + // Compare with TypeScript output + assertEquals(EXPECTED_POSEIDON5_VECTOR, result) + } + + @Test + fun `packBytesAndPoseidon matches TypeScript`() { + // Test with known MRZ bytes + val mrzBytes = "P): List { + val result = mutableListOf() + for (chunk in unpacked.chunked(MAX_BYTES_IN_FIELD)) { + var packed = BigInteger.ZERO + for ((i, byte) in chunk.withIndex()) { + packed = packed + (byte.toBigInteger() shl (i * 8)) + } + result.add(packed) + } + return result + } + + /** + * Split a BigInt into k words of n bits each. + * Used for formatting RSA keys and signatures for circuit inputs. + * + * Port of splitToWords from common/src/utils/bytes.ts + */ + fun splitToWords(number: BigInteger, wordSize: Int, numWords: Int): List { + val mask = (BigInteger.ONE shl wordSize) - BigInteger.ONE + return (0 until numWords).map { i -> + ((number shr (i * wordSize)) and mask).toString() + } + } + + /** + * Convert hex string to decimal string. + */ + fun hexToDecimal(hex: String): String { + return BigInteger(hex, 16).toString() + } + + /** + * Convert hex string to signed byte array (values -128 to 127). + */ + fun hexToSignedBytes(hex: String): List { + val cleanHex = hex.removePrefix("0x") + return cleanHex.chunked(2).map { it.toInt(16).toByte().toInt() } + } + + /** + * Convert number to n-bit binary array (LSB first). + */ + fun num2Bits(numBits: Int, value: BigInteger): List { + return (0 until numBits).map { i -> + (value shr i) and BigInteger.ONE + } + } + + /** + * Convert byte array to decimal string via BigInt. + */ + fun bytesToBigDecimal(bytes: List): String { + var result = BigInteger.ZERO + for (byte in bytes) { + result = (result shl 8) + (byte and 0xFF).toBigInteger() + } + return result.toString() + } +} +``` + +#### Testing + +```kotlin +class BytePackingTest { + @Test + fun `packBytesArray packs 31 bytes into one field element`() { + val bytes = (1..31).toList() + val packed = BytePacking.packBytesArray(bytes) + assertEquals(1, packed.size) + // Verify: 1 + 2*256 + 3*65536 + ... + } + + @Test + fun `packBytesArray splits at 31-byte boundary`() { + val bytes = (0..62).toList() // 63 bytes = 3 chunks (31 + 31 + 1) + val packed = BytePacking.packBytesArray(bytes) + assertEquals(3, packed.size) + } + + @Test + fun `splitToWords decomposes RSA modulus correctly`() { + val n = BigInteger("65537") + val words = BytePacking.splitToWords(n, wordSize = 8, numWords = 4) + assertEquals(listOf("1", "0", "1", "0"), words) // 65537 = 0x10001 + } + + @Test + fun `num2Bits converts correctly`() { + val bits = BytePacking.num2Bits(8, BigInteger.valueOf(5)) + // 5 = 101 in binary, LSB first = [1, 0, 1, 0, 0, 0, 0, 0] + assertEquals(BigInteger.ONE, bits[0]) + assertEquals(BigInteger.ZERO, bits[1]) + assertEquals(BigInteger.ONE, bits[2]) + } +} +``` + +--- + +### 3. SHA Padding + +Port of `common/src/utils/shaPad.ts`. Used to prepare data for circuit SHA verification. + +```kotlin +object ShaPad { + /** + * SHA-1/SHA-224/SHA-256 padding (512-bit blocks). + * + * 1. Append 0x80 + * 2. Pad with zeros until (length_bits + 64) % 512 == 0 + * 3. Append 64-bit big-endian message length + * 4. Zero-pad to maxShaBytes + * + * @param message Input byte array + * @param maxShaBytes Maximum padded output size + * @return Pair of (padded bytes, actual message bit length) + */ + fun shaPad(message: List, maxShaBytes: Int): Pair, Int> { + val msgLen = message.size + val bitLen = msgLen * 8 + + val result = message.toMutableList() + result.add(0x80) + + // Pad to 512-bit boundary (minus 64 bits for length) + while ((result.size * 8 + 64) % 512 != 0) { + result.add(0) + } + + // Append 64-bit big-endian message length + for (i in 56 downTo 0 step 8) { + result.add((bitLen.toLong() shr i).toInt() and 0xFF) + } + + // Zero-pad to maxShaBytes + while (result.size < maxShaBytes) { + result.add(0) + } + + return Pair(result, bitLen) + } + + /** + * SHA-384/SHA-512 padding (1024-bit blocks). + * + * Same as shaPad but: + * - Uses 128-bit message length + * - Pads to 1024-bit block boundary + */ + fun sha384_512Pad(message: List, maxShaBytes: Int): Pair, Int> { + val msgLen = message.size + val bitLen = msgLen * 8 + + val result = message.toMutableList() + result.add(0x80) + + // Pad to 1024-bit boundary (minus 128 bits for length) + while ((result.size * 8 + 128) % 1024 != 0) { + result.add(0) + } + + // Append 128-bit big-endian message length (upper 64 bits are zero for our sizes) + for (i in 0 until 8) result.add(0) // Upper 64 bits + for (i in 56 downTo 0 step 8) { + result.add((bitLen.toLong() shr i).toInt() and 0xFF) + } + + // Zero-pad to maxShaBytes + while (result.size < maxShaBytes) { + result.add(0) + } + + return Pair(result, bitLen) + } + + /** + * Select correct padding function based on hash algorithm. + */ + fun pad(hashAlgorithm: String): (List, Int) -> Pair, Int> { + return when (hashAlgorithm.lowercase()) { + "sha1", "sha224", "sha256" -> ::shaPad + "sha384", "sha512" -> ::sha384_512Pad + else -> throw IllegalArgumentException("Unsupported hash algorithm: $hashAlgorithm") + } + } +} +``` + +#### Testing + +```kotlin +class ShaPadTest { + @Test + fun `sha256 padding appends 0x80 and length`() { + val msg = listOf(0x61, 0x62, 0x63) // "abc" + val (padded, bitLen) = ShaPad.shaPad(msg, 64) + assertEquals(24, bitLen) + assertEquals(0x80, padded[3]) + // Last 8 bytes = 64-bit big-endian length = 24 = 0x18 + assertEquals(0x18, padded[63]) + } + + @Test + fun `sha384 padding uses 1024-bit blocks`() { + val msg = (0 until 100).map { it and 0xFF } + val (padded, _) = ShaPad.sha384_512Pad(msg, 256) + assertEquals(256, padded.size) + // Verify block alignment + assertTrue((padded.indexOf(0x80) * 8 + 128) <= padded.size * 8) + } +} +``` + +--- + +### 4. LeanIMT (Lean Incremental Merkle Tree) + +Port of `@openpassport/zk-kit-lean-imt`. Used for commitment tree and DSC tree lookups. + +```kotlin +/** + * Lean Incremental Merkle Tree — binary hash tree with ordered insertion. + * + * Serialization format: JSON object with "nodes" array of arrays of BigInt strings. + * Level 0 = leaves, Level n = root. + * + * Used for: + * - Commitment tree: user registration lookups (depth 33) + * - DSC tree: document signing certificate lookups (depth 21) + */ +class LeanIMT( + private val hashFn: (BigInteger, BigInteger) -> BigInteger, +) { + private val nodes: MutableList> = mutableListOf() + + val root: BigInteger + get() = if (nodes.isEmpty()) BigInteger.ZERO + else nodes.last().firstOrNull() ?: BigInteger.ZERO + + val size: Int + get() = if (nodes.isEmpty()) 0 else nodes[0].size + + /** + * Find the index of a leaf in the tree. + * @return Index (0-based) or -1 if not found + */ + fun indexOf(leaf: BigInteger): Int { + if (nodes.isEmpty()) return -1 + return nodes[0].indexOf(leaf) + } + + /** + * Generate an inclusion proof for a leaf at the given index. + * @return MerkleProof with siblings and path indices + */ + fun generateProof(index: Int): LeanIMTProof { + require(index in 0 until size) { "Index $index out of range [0, $size)" } + + val siblings = mutableListOf() + val pathIndices = mutableListOf() + var currentIndex = index + + for (level in 0 until nodes.size - 1) { + val siblingIndex = if (currentIndex % 2 == 0) currentIndex + 1 else currentIndex - 1 + pathIndices.add(currentIndex % 2) + + if (siblingIndex < nodes[level].size) { + siblings.add(nodes[level][siblingIndex]) + } else { + siblings.add(BigInteger.ZERO) // Padding for incomplete level + } + currentIndex /= 2 + } + + return LeanIMTProof( + root = root, + leaf = nodes[0][index], + siblings = siblings, + pathIndices = pathIndices, + ) + } + + companion object { + /** + * Import a tree from serialized JSON string. + * Format: {"nodes": [["leaf0", "leaf1", ...], ["node0", ...], ..., ["root"]]} + */ + fun import( + hashFn: (BigInteger, BigInteger) -> BigInteger, + serialized: String, + ): LeanIMT { + val tree = LeanIMT(hashFn) + val json = Json.parseToJsonElement(serialized).jsonObject + val nodesArray = json["nodes"]?.jsonArray + ?: throw IllegalArgumentException("Missing 'nodes' in serialized tree") + + for (level in nodesArray) { + val levelNodes = level.jsonArray.map { it.jsonPrimitive.content.toBigInteger() } + tree.nodes.add(levelNodes.toMutableList()) + } + return tree + } + } +} + +data class LeanIMTProof( + val root: BigInteger, + val leaf: BigInteger, + val siblings: List, + val pathIndices: List, +) +``` + +#### `generateMerkleProof` wrapper (matches TypeScript API) + +```kotlin +/** + * Generate Merkle proof padded to a fixed depth. + * Port of generateMerkleProof from common/src/utils/trees.ts + */ +fun generateMerkleProof( + tree: LeanIMT, + index: Int, + maxLeafDepth: Int, +): PaddedMerkleProof { + val proof = tree.generateProof(index) + + // Pad siblings and path to maxLeafDepth + val paddedSiblings = proof.siblings.toMutableList() + val paddedPath = proof.pathIndices.toMutableList() + while (paddedSiblings.size < maxLeafDepth) { + paddedSiblings.add(BigInteger.ZERO) + paddedPath.add(0) + } + + return PaddedMerkleProof( + root = proof.root, + siblings = paddedSiblings, + path = paddedPath, + leafDepth = proof.siblings.size, + ) +} + +data class PaddedMerkleProof( + val root: BigInteger, + val siblings: List, + val path: List, + val leafDepth: Int, +) +``` + +#### Testing + +```kotlin +class LeanIMTTest { + private val hashFn = { a: BigInteger, b: BigInteger -> poseidon2(a, b) } + + @Test + fun `import and indexOf finds existing leaf`() { + // Serialize a small tree in TypeScript, import here + val serialized = """{"nodes":[["1","2","3","4"],["${poseidon2(1.bi, 2.bi)}","${poseidon2(3.bi, 4.bi)}"],["${poseidon2(poseidon2(1.bi, 2.bi), poseidon2(3.bi, 4.bi))}"]]}""" + val tree = LeanIMT.import(hashFn, serialized) + + assertEquals(0, tree.indexOf(BigInteger.ONE)) + assertEquals(2, tree.indexOf(3.toBigInteger())) + assertEquals(-1, tree.indexOf(99.toBigInteger())) + } + + @Test + fun `generateProof creates valid inclusion proof`() { + // Import tree, generate proof, verify manually + val tree = LeanIMT.import(hashFn, KNOWN_TREE_JSON) + val proof = tree.generateProof(0) + + // Verify: hash up the path and check root matches + var current = proof.leaf + for (i in proof.siblings.indices) { + current = if (proof.pathIndices[i] == 0) + hashFn(current, proof.siblings[i]) + else + hashFn(proof.siblings[i], current) + } + assertEquals(tree.root, current) + } + + @Test + fun `import real commitment tree from staging API`() { + // Use a snapshot of a real serialized tree for integration testing + val tree = LeanIMT.import(hashFn, STAGING_COMMITMENT_TREE_SNAPSHOT) + assertTrue(tree.size > 0) + assertNotEquals(BigInteger.ZERO, tree.root) + } +} +``` + +--- + +### 5. Sparse Merkle Tree (SMT) + +Port of `@openpassport/zk-kit-smt`. Used for OFAC sanctions list checking. + +```kotlin +/** + * Sparse Merkle Tree — key-value tree supporting membership and non-membership proofs. + * + * Hash function takes 2 children (internal nodes) or 3 elements (leaf: key, value, 1). + * Tree depth is fixed (OFAC_TREE_LEVELS = 64). + * + * Used for OFAC sanctions checking: + * - nameAndDob tree + * - nameAndYob tree + * - passportNoAndNationality tree (passport only) + */ +class SparseMerkleTree( + private val hashFn: (List) -> BigInteger, + private val bigNumbers: Boolean = true, +) { + private val nodes: MutableMap = mutableMapOf() + private val entries: MutableMap> = mutableMapOf() + var root: BigInteger = BigInteger.ZERO + private set + + fun add(key: BigInteger, value: BigInteger) { /* ... */ } + + /** + * Create a membership or non-membership proof. + * + * @return SmtProof with entry, closest leaf, siblings, root, and membership flag + */ + fun createProof(key: BigInteger): SmtProof { /* ... */ } + + companion object { + fun import(hashFn: (List) -> BigInteger, serialized: String): SparseMerkleTree { + // Deserialize from JSON + } + } +} + +data class SmtProof( + val entry: Pair, // (key, value) being proven + val matchingEntry: Pair?, // Closest existing entry (non-membership) + val siblings: List, + val root: BigInteger, + val membership: Boolean, // true = member, false = non-member +) +``` + +#### `generateSMTProof` wrapper + +```kotlin +/** + * Generate SMT proof padded to OFAC_TREE_LEVELS. + * Port of generateSMTProof from common/src/utils/trees.ts + */ +fun generateSMTProof( + smt: SparseMerkleTree, + leaf: BigInteger, +): PaddedSmtProof { + val proof = smt.createProof(leaf) + + // Pad siblings to OFAC_TREE_LEVELS, reversed + val paddedSiblings = proof.siblings.reversed().toMutableList() + while (paddedSiblings.size < OFAC_TREE_LEVELS) { + paddedSiblings.add(BigInteger.ZERO) + } + + return PaddedSmtProof( + root = proof.root, + closestLeaf = if (proof.matchingEntry != null) + listOf(proof.matchingEntry.first, proof.matchingEntry.second) + else + listOf(BigInteger.ZERO, BigInteger.ZERO), + siblings = paddedSiblings, + leafDepth = proof.siblings.size, + ) +} +``` + +--- + +### 6. OFAC Leaf Generation + +Port of leaf generation functions from `common/src/utils/trees.ts`. + +```kotlin +object LeafGenerators { + /** + * Generate name + DOB leaf for OFAC SMT. + * name: 39 MRZ characters (passport) or 30 (ID card) + * dob: 6 MRZ characters (YYMMDD) + * + * Hash: poseidon3(nameHash, poseidon6(dob[0..5])) + * Where nameHash = poseidon3(poseidon13(name[0..12]), poseidon13(name[13..25]), poseidon13(name[26..38])) + */ + fun getNameDobLeaf(name: List, dob: List): BigInteger { + val nameHash = getNameLeaf(name) + val dobHash = poseidon(dob.take(6)) // poseidon6 + return poseidon3(nameHash, dobHash, BigInteger.ZERO) // Or appropriate combination + } + + /** + * Generate name leaf hash. + * Passport (39 chars): 3 chunks of 13 → poseidon13 each → poseidon3 combine + * ID card (30 chars): 3 chunks of 10 → poseidon10 each → poseidon3 combine + */ + fun getNameLeaf(name: List): BigInteger { + val chunkSize = if (name.size <= 30) 10 else 13 + val chunks = name.chunked(chunkSize) + val chunkHashes = chunks.map { poseidon(it) } + return poseidon(chunkHashes) + } + + fun getNameYobLeaf(name: List, yob: List): BigInteger { + val nameHash = getNameLeaf(name) + val yobHash = poseidon(yob.take(2)) // poseidon2 + return poseidon3(nameHash, yobHash, BigInteger.ZERO) + } + + fun getPassportNumberAndNationalityLeaf( + passportNumber: List, + nationality: List, + ): BigInteger { + // poseidon12: 9 passport digits + 3 nationality chars + return poseidon(passportNumber.take(9) + nationality.take(3)) + } + + fun getCountryLeaf(countryFrom: List, countryTo: List): BigInteger { + return poseidon(countryFrom.take(3) + countryTo.take(3)) // poseidon6 + } +} +``` + +--- + +### 7. MRZ Formatter + +Port of `formatMrz` from `common/src/utils/passports/format.ts`. + +```kotlin +object MrzFormatter { + /** + * Format raw MRZ string into DER/TLV-encoded byte array for DG1 hashing. + * + * Prepends ASN.1 tags: + * 0x61 (DG1 tag) | length | 0x5F 0x1F (MRZ_INFO tag) | MRZ length | MRZ bytes + * + * @param mrz Raw MRZ string (88 chars for passport, 90 for ID card) + * @return Byte array with TLV encoding + */ + fun format(mrz: String): List { + val mrzBytes = mrz.map { it.code }.toMutableList() + + when (mrz.length) { + 88 -> { + mrzBytes.add(0, 88) // MRZ data length + mrzBytes.add(0, 0x1F) // MRZ_INFO tag byte 2 + mrzBytes.add(0, 0x5F) // MRZ_INFO tag byte 1 + mrzBytes.add(0, 91) // Total content length + mrzBytes.add(0, 0x61) // DG1 tag + } + 90 -> { + mrzBytes.add(0, 90) + mrzBytes.add(0, 0x1F) + mrzBytes.add(0, 0x5F) + mrzBytes.add(0, 93) + mrzBytes.add(0, 0x61) + } + else -> throw IllegalArgumentException("Unsupported MRZ length: ${mrz.length}") + } + + return mrzBytes + } +} +``` + +#### Testing + +```kotlin +class MrzFormatterTest { + @Test + fun `format passport MRZ adds correct TLV tags`() { + val mrz = "P) : Asn1Element() + data class Integer(val value: BigInteger) : Asn1Element() + data class BitString(val bytes: ByteArray, val unusedBits: Int) : Asn1Element() + data class OctetString(val bytes: ByteArray) : Asn1Element() + data class ObjectIdentifier(val oid: String) : Asn1Element() + data class Utf8String(val value: String) : Asn1Element() + data class PrintableString(val value: String) : Asn1Element() + data class UtcTime(val value: String) : Asn1Element() + data class GeneralizedTime(val value: String) : Asn1Element() + data class ContextSpecific(val tag: Int, val bytes: ByteArray) : Asn1Element() + data class Unknown(val tag: Int, val bytes: ByteArray) : Asn1Element() +} +``` + +```kotlin +/** + * Parse X.509 certificate PEM into structured CertificateData. + * + * Port of parseCertificateSimple from common/src/utils/certificate_parsing/ + */ +object X509CertificateParser { + fun parse(pem: String): CertificateData { + val der = pemToDer(pem) + val root = Asn1Parser.parse(der) as Asn1Element.Sequence + + val tbs = root.elements[0] as Asn1Element.Sequence + val tbsBytes = Asn1Parser.extractTbs(der) + + val signatureAlgorithmOid = extractSignatureAlgorithmOid(tbs) + val publicKeyInfo = extractPublicKeyInfo(tbs) + val validity = extractValidity(tbs) + val extensions = extractExtensions(tbs) + val ski = extractSki(extensions) + val aki = extractAki(extensions) + + return CertificateData( + tbsBytes = tbsBytes.map { it.toInt() and 0xFF }, + tbsBytesLength = tbsBytes.size.toString(), + signatureAlgorithm = OidResolver.resolveSignatureAlgorithm(signatureAlgorithmOid), + hashAlgorithm = OidResolver.resolveHashAlgorithm(signatureAlgorithmOid), + publicKeyDetails = publicKeyInfo, + subjectKeyIdentifier = ski ?: "", + authorityKeyIdentifier = aki ?: "", + validity = validity, + rawPem = pem, + ) + } + + private fun pemToDer(pem: String): ByteArray { + val base64 = pem.lines() + .filter { !it.startsWith("-----") } + .joinToString("") + return base64Decode(base64) + } +} +``` + +#### OID Resolution + +```kotlin +object OidResolver { + private val signatureAlgorithms = mapOf( + "1.2.840.113549.1.1.5" to "rsa", // sha1WithRSAEncryption + "1.2.840.113549.1.1.11" to "rsa", // sha256WithRSAEncryption + "1.2.840.113549.1.1.12" to "rsa", // sha384WithRSAEncryption + "1.2.840.113549.1.1.13" to "rsa", // sha512WithRSAEncryption + "1.2.840.113549.1.1.10" to "rsapss", // RSASSA-PSS + "1.2.840.10045.4.1" to "ecdsa", // ecdsaWithSHA1 + "1.2.840.10045.4.3.2" to "ecdsa", // ecdsaWithSHA256 + "1.2.840.10045.4.3.3" to "ecdsa", // ecdsaWithSHA384 + "1.2.840.10045.4.3.4" to "ecdsa", // ecdsaWithSHA512 + ) + + private val curves = mapOf( + "1.2.840.10045.3.1.7" to "secp256r1", + "1.3.132.0.34" to "secp384r1", + "1.3.132.0.35" to "secp521r1", + "1.3.36.3.3.2.8.1.1.7" to "brainpoolP256r1", + "1.3.36.3.3.2.8.1.1.9" to "brainpoolP320r1", + "1.3.36.3.3.2.8.1.1.11" to "brainpoolP384r1", + "1.3.36.3.3.2.8.1.1.13" to "brainpoolP512r1", + ) + + fun resolveSignatureAlgorithm(oid: String): String = signatureAlgorithms[oid] ?: "unknown" + fun resolveCurve(oid: String): String = curves[oid] ?: "unknown" + fun resolveHashAlgorithm(sigOid: String): String { /* map OID → sha256, etc */ } +} +``` + +#### Testing + +```kotlin +class X509CertificateParserTest { + @Test + fun `parse RSA DSC certificate`() { + val cert = X509CertificateParser.parse(KNOWN_RSA_DSC_PEM) + assertEquals("rsa", cert.signatureAlgorithm) + assertEquals("sha256", cert.hashAlgorithm) + assertNotNull(cert.publicKeyDetails as? PublicKeyDetailsRSA) + assertTrue(cert.tbsBytes.isNotEmpty()) + } + + @Test + fun `parse ECDSA DSC certificate`() { + val cert = X509CertificateParser.parse(KNOWN_ECDSA_DSC_PEM) + assertEquals("ecdsa", cert.signatureAlgorithm) + val ecDetails = cert.publicKeyDetails as PublicKeyDetailsECDSA + assertEquals("secp256r1", ecDetails.curve) + assertTrue(ecDetails.x.isNotEmpty()) + assertTrue(ecDetails.y.isNotEmpty()) + } + + @Test + fun `extract SKI and AKI`() { + val cert = X509CertificateParser.parse(KNOWN_DSC_PEM) + assertTrue(cert.subjectKeyIdentifier.isNotEmpty()) + assertTrue(cert.authorityKeyIdentifier.isNotEmpty()) + } + + @Test + fun `TBS bytes match TypeScript output`() { + // Compare tbsBytes from Kotlin vs TypeScript for the same certificate + val cert = X509CertificateParser.parse(TEST_CERT_PEM) + assertEquals(EXPECTED_TBS_BYTES, cert.tbsBytes) + } +} +``` + +--- + +### 11. Passport Data Parser + +Port of `initPassportDataParsing` and `parsePassportData`. + +```kotlin +object PassportDataParser { + /** + * Parse raw NFC scan output into structured PassportData with metadata. + * + * Extracts: + * - DG1 hash function and location in eContent + * - eContent hash algorithm + * - Signed attributes hash algorithm + * - Signature algorithm (RSA, ECDSA, RSA-PSS) with key details + * - CSCA certificate (if found via SKI lookup) + * - Country code from MRZ + * + * Port of initPassportDataParsing + parsePassportData + */ + fun parse( + mrz: String, + eContent: ByteArray, + signedAttr: ByteArray, + dscPem: String, + skiPem: Map? = null, + ): ParsedPassportData { + val dscParsed = X509CertificateParser.parse(dscPem) + + // Extract country code from MRZ (positions 2-4 for passport) + val countryCode = mrz.substring(2, 5).replace("<", "") + + // Find DG1 hash in eContent (try each hash algorithm) + val (dg1HashFunction, dg1HashOffset, dg1HashSize) = findDg1HashInEContent(mrz, eContent) + + // Determine eContent hash algorithm + val eContentHashFunction = findEContentHashFunction(eContent, signedAttr) + + // Determine signature algorithm (may require brute-force detection) + val signedAttrHashFunction = findSignedAttrHashFunction(signedAttr) + + // Parse DSC certificate for algorithm details + val signatureAlgorithm = dscParsed.signatureAlgorithm + val curveOrExponent = when (val pk = dscParsed.publicKeyDetails) { + is PublicKeyDetailsRSA -> pk.exponent + is PublicKeyDetailsECDSA -> pk.curve + is PublicKeyDetailsRSAPSS -> pk.exponent + } + + // Look up CSCA by SKI + val aki = dscParsed.authorityKeyIdentifier + val cscaPem = skiPem?.get(aki) + val cscaParsed = cscaPem?.let { X509CertificateParser.parse(it) } + + return ParsedPassportData( + passportMetadata = PassportMetadata( + countryCode = countryCode, + cscaFound = cscaParsed != null, + dg1HashFunction = dg1HashFunction, + eContentHashFunction = eContentHashFunction, + signedAttrHashFunction = signedAttrHashFunction, + signatureAlgorithm = signatureAlgorithm, + curveOrExponent = curveOrExponent, + // ... other fields + ), + dscParsed = dscParsed, + cscaParsed = cscaParsed, + ) + } +} +``` + +--- + +### 12. Selector Generator + +Port of `getSelectorDg1` from `common/src/utils/circuits/registerInputs.ts`. (Moved here from proving client spec since it's pure data mapping.) + +```kotlin +object SelectorGenerator { + 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), + ) + + 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), + ) + + /** + * Generate DG1 selector bit array marking which MRZ bytes to reveal. + * + * @param category passport (88 bits) or id_card (90 bits) + * @param revealedAttributes List of attribute names to reveal + * @return List of "0" and "1" strings + */ + fun getDg1Selector(category: String, revealedAttributes: List): List { + val size = if (category == "id_card") 90 else 88 + val positions = if (category == "id_card") idCardPositions else passportPositions + val selector = MutableList(size) { "0" } + + for (attr in revealedAttributes) { + if (attr in listOf("ofac", "excludedCountries", "minimumAge")) continue + positions[attr]?.let { range -> + for (i in range) selector[i] = "1" + } + } + return selector + } +} +``` + +--- + +## Constants + +```kotlin +object Constants { + // Tree depths + const val DSC_TREE_DEPTH = 21 + const val CSCA_TREE_DEPTH = 12 + const val COMMITMENT_TREE_DEPTH = 33 + const val OFAC_TREE_LEVELS = 64 + + // Max padded sizes (bytes) + const val MAX_DSC_BYTES = 4000 + const val MAX_CSCA_BYTES = 4000 + const val MAX_PADDED_ECONTENT_LEN_SHA256 = 512 + const val MAX_PADDED_SIGNED_ATTR_LEN_SHA256 = 256 + + // RSA word sizes for circuit inputs + const val N_DSC = 32; const val K_DSC = 64 // 2048-bit + const val N_DSC_3072 = 32; const val K_DSC_3072 = 96 // 3072-bit + const val N_DSC_4096 = 32; const val K_DSC_4096 = 128 // 4096-bit + const val N_DSC_ECDSA = 64; const val K_DSC_ECDSA = 4 // P-256 + + // Attestation IDs + const val PASSPORT_ATTESTATION_ID = "1" + const val ID_CARD_ATTESTATION_ID = "2" + const val AADHAAR_ATTESTATION_ID = "3" + const val KYC_ATTESTATION_ID = "4" +} +``` + +--- + +## Chunking Guide + +### Chunk 6A: Poseidon Hash + Byte Packing (start here, hardest) + +**Goal**: Working Poseidon hash that produces identical outputs to `poseidon-lite`. + +**Steps**: +1. Implement `BigIntField.kt` — modular arithmetic over BN254 prime +2. Extract round constants and mixing matrices from `poseidon-lite` npm package → `PoseidonConstants.kt` +3. Implement `Poseidon.kt` — core algorithm (pow5, addRoundConstants, mix) +4. Implement convenience functions (poseidon2 through poseidon16) +5. Implement `BytePacking.kt` — packBytesArray, splitToWords, etc. +6. Implement `FlexiblePoseidon.kt` — flexiblePoseidon, customHasher, packBytesAndPoseidon +7. **Test**: Compare Poseidon outputs against TypeScript for 50+ test vectors covering all variants (poseidon1 through poseidon16) +8. **Test**: packBytesAndPoseidon with known byte arrays matches TypeScript +9. Validate: `./gradlew :shared:jvmTest` + +**Test vector generation**: Create a TypeScript script that outputs test vectors: +```typescript +// scripts/generate-poseidon-vectors.ts +import { poseidon1, poseidon2, ..., poseidon16 } from 'poseidon-lite' +const vectors = [ + { fn: 'poseidon2', inputs: [1n, 2n], output: poseidon2([1n, 2n]).toString() }, + { fn: 'poseidon2', inputs: [0n, 0n], output: poseidon2([0n, 0n]).toString() }, + // ... edge cases: max field element, zero, large values +] +console.log(JSON.stringify(vectors, null, 2)) +``` + +**This is the highest-risk chunk** — if Poseidon outputs don't match, nothing downstream works. + +### Chunk 6B: SHA Padding + MRZ Formatter + +**Goal**: Circuit-compatible SHA padding and MRZ formatting. + +**Steps**: +1. Implement `ShaPad.kt` — shaPad, sha384_512Pad +2. Implement `MrzFormatter.kt` — formatMrz with TLV encoding +3. Implement `Sha.kt` — SHA-1/256/384/512 (pure Kotlin or use `kotlinx-io` / third-party) +4. **Test**: SHA padding matches TypeScript for known inputs +5. **Test**: formatMrz produces correct byte arrays for 88-char and 90-char MRZ strings +6. Validate: `./gradlew :shared:jvmTest` + +### Chunk 6C: LeanIMT + Sparse Merkle Tree + +**Goal**: Tree data structures with import, lookup, and proof generation. + +**Steps**: +1. Implement `LeanIMT.kt` — import from JSON, indexOf, generateProof +2. Implement `MerkleProof.kt` — padded proof generation wrapper +3. Implement `SparseMerkleTree.kt` — import, add, createProof +4. Implement `LeafGenerators.kt` — all OFAC leaf functions +5. Implement `TreeConstants.kt` — depth constants +6. **Test**: Import a snapshot of a real commitment tree, verify root hash +7. **Test**: indexOf finds known leaves, returns -1 for unknown +8. **Test**: generateProof creates verifiable proofs (hash up the path = root) +9. **Test**: SMT membership and non-membership proofs +10. **Test**: Leaf generators produce same values as TypeScript +11. Validate: `./gradlew :shared:jvmTest` + +**Test fixture strategy**: Snapshot real trees from the staging API. Store as resource files in `commonTest/resources/`. + +### Chunk 6D: ASN.1 Parser + Certificate Parser + +**Goal**: Parse X.509 certificates from PEM into structured data. + +**Steps**: +1. Implement `Asn1Parser.kt` — minimal DER parser (SEQUENCE, INTEGER, BIT STRING, OID, OCTET STRING, context-specific tags) +2. Implement `OidResolver.kt` — OID → algorithm/curve name mapping +3. Implement `X509CertificateParser.kt` — parseCertificateSimple +4. Implement `CscaLookup.kt` — SKI → CSCA PEM mapping +5. Implement `SignatureExtractor.kt` — extract r,s from DER ECDSA signatures +6. **Test**: Parse known RSA DSC certificate → verify modulus, exponent, TBS bytes +7. **Test**: Parse known ECDSA DSC certificate → verify x, y, curve +8. **Test**: Parse known RSA-PSS certificate → verify hash, mgf, salt length +9. **Test**: SKI/AKI extraction matches TypeScript +10. **Test**: TBS bytes match TypeScript output byte-for-byte +11. Validate: `./gradlew :shared:jvmTest` + +**Test certificates**: Use mock certificates from `common/src/constants/mockCertificates.ts` and real DSC certificates from the staging API. + +### Chunk 6E: Passport Data Parser + Commitment/Nullifier + +**Goal**: End-to-end passport parsing and commitment generation. + +**Steps**: +1. Implement `PassportDataParser.kt` — initPassportDataParsing equivalent +2. Implement `CommitmentGenerator.kt` — generateCommitment +3. Implement `NullifierGenerator.kt` — generateNullifier +4. Implement `DscLeaf.kt` — getLeafDscTree +5. Implement `SelectorGenerator.kt` — getDg1Selector +6. **Test**: Parse mock passport data → verify all metadata fields match TypeScript +7. **Test**: Generate commitment for known passport + secret → matches TypeScript +8. **Test**: Generate nullifier for known passport → matches TypeScript +9. **Test**: DSC tree leaf hash matches TypeScript +10. **Test**: Selector bits for known disclosure flags match TypeScript +11. **Integration test**: Parse mock passport → generate commitment → look up in imported tree +12. Validate: `./gradlew :shared:jvmTest` + +**This chunk proves the entire pipeline works**: raw data → parsed metadata → hashed commitment → tree lookup. + +--- + +## Testing Strategy + +### Test Vector Generation + +Create a one-time TypeScript script (`scripts/generate-kmp-test-vectors.ts`) that outputs all needed test vectors: + +```typescript +// Run: npx ts-node scripts/generate-kmp-test-vectors.ts > test-vectors.json + +import { poseidon2, poseidon5 } from 'poseidon-lite' +import { LeanIMT } from '@openpassport/zk-kit-lean-imt' +import { genAndInitMockPassportData } from '../common/src/utils/passports/mock' +import { generateCommitment } from '../common/src/utils/passports/passport' +import { packBytesAndPoseidon } from '../common/src/utils/hash' +import { formatMrz } from '../common/src/utils/passports/format' +import { parseCertificateSimple } from '../common/src/utils/certificate_parsing' + +const vectors = { + poseidon: [ + { inputs: ['1', '2'], output: poseidon2([1n, 2n]).toString() }, + // ... 50+ vectors for all poseidon1-16 + ], + packBytesAndPoseidon: [ + { bytes: [1, 2, 3, 4, 5], output: packBytesAndPoseidon([1, 2, 3, 4, 5]).toString() }, + // ... various lengths + ], + formatMrz: [ + { mrz: "P Unit, onComplete: (Any) -> Unit, onError: (String) -> Unit) + } + object NfcScanFactory { + var instance: NfcScanViewFactory? = null + } + ``` + +2. **Swift side** (`iosApp/`): Implementation registered at app startup + ```swift + // NfcScanFactoryImpl.swift + class NfcScanFactoryImpl: NSObject, NfcScanViewFactory { + static func register() { + NfcScanFactory.shared.instance = NfcScanFactoryImpl() + } + func scanPassport(...) { /* calls NfcPassportHelper */ } + } + + // iOSApp.swift + @main struct iOSApp: App { + init() { + NfcScanFactoryImpl.register() + MrzCameraFactoryImpl.register() + } + } + ``` + +### What Changes + +Move factory interfaces **into the SDK** (`kmp-sdk/shared/src/iosMain/`), not the test app. The SDK's iOS handlers call the registered factories instead of throwing `NotImplementedError`. A new Swift companion package (`SelfSdkSwift/`) provides default implementations. + +### Key Design Principles + +- **cinterop stays disabled** — `build.gradle.kts` lines 32–62 remain commented out +- **No new Kotlin/Native framework dependencies** — all Apple framework calls happen in Swift +- **Callback-based APIs** — Swift closures bridge to Kotlin `suspend` functions via `suspendCancellableCoroutine` +- **Main thread safety** — Swift callbacks dispatch to main queue before calling Kotlin +- **ARC lifecycle management** — Swift factory impls retain helpers during async operations (prevents premature deallocation) + +--- + +## Directory Structure + +``` +packages/kmp-sdk/ + shared/src/iosMain/kotlin/xyz/self/sdk/ + providers/ # NEW — Factory interfaces for all handlers + NfcProvider.kt # NFC passport scanning + BiometricProvider.kt # Face ID / Touch ID + SecureStorageProvider.kt # Keychain access + CryptoProvider.kt # Key generation, signing + CameraMrzProvider.kt # MRZ camera scanning + HapticProvider.kt # Vibration feedback + DocumentsProvider.kt # Encrypted document storage + WebViewProvider.kt # WKWebView hosting + SdkProviderRegistry.kt # Central registry for all providers + handlers/ # REWRITE — Use providers instead of stubs + NfcBridgeHandler.kt + BiometricBridgeHandler.kt + SecureStorageBridgeHandler.kt + CryptoBridgeHandler.kt + CameraMrzBridgeHandler.kt + HapticBridgeHandler.kt + AnalyticsBridgeHandler.kt # Stays as fire-and-forget (no provider needed) + LifecycleBridgeHandler.kt + DocumentsBridgeHandler.kt + webview/ + IosWebViewHost.kt # REWRITE — Uses WebViewProvider + api/ + SelfSdk.ios.kt # UPDATE — Uses SdkProviderRegistry + +packages/self-sdk-swift/ # NEW — Swift companion package + Package.swift # SPM package definition + Sources/SelfSdkSwift/ + SelfSdkSwift.swift # Public setup API: SelfSdkSwift.configure() + Providers/ + NfcProviderImpl.swift # Wraps NfcPassportHelper + BiometricProviderImpl.swift # LAContext wrapper + SecureStorageProviderImpl.swift # Keychain wrapper + CryptoProviderImpl.swift # SecKey wrapper + CameraMrzProviderImpl.swift # Wraps MrzCameraHelper + HapticProviderImpl.swift # UIImpactFeedbackGenerator + DocumentsProviderImpl.swift # Encrypted file storage + WebViewProviderImpl.swift # WKWebView wrapper + Helpers/ + NfcPassportHelper.swift # MOVE from test app (274 lines) + MrzCameraHelper.swift # MOVE from test app (322 lines) +``` + +--- + +## Chunk 3A: Factory Infrastructure + +**Goal**: Define all provider interfaces in the SDK and create the Swift companion package skeleton. + +### Step 1: Provider Interfaces (Kotlin `iosMain`) + +#### `SdkProviderRegistry.kt` + +Central registry that all providers register into. The SDK checks this before attempting operations. + +```kotlin +package xyz.self.sdk.providers + +/** + * Central registry for iOS native provider implementations. + * Swift companion package calls SdkProviderRegistry.configure() at app startup. + */ +object SdkProviderRegistry { + var nfc: NfcProvider? = null + var biometric: BiometricProvider? = null + var secureStorage: SecureStorageProvider? = null + var crypto: CryptoProvider? = null + var cameraMrz: CameraMrzProvider? = null + var haptic: HapticProvider? = null + var documents: DocumentsProvider? = null + var webView: WebViewProvider? = null + + /** + * Returns true if all required providers are registered. + * Analytics and Lifecycle don't need external providers. + */ + fun isConfigured(): Boolean = nfc != null && biometric != null && + secureStorage != null && crypto != null && cameraMrz != null && + documents != null && webView != null +} +``` + +#### `NfcProvider.kt` + +```kotlin +package xyz.self.sdk.providers + +/** + * Provider interface for iOS NFC passport scanning. + * Swift implementation wraps NFCPassportReader library. + */ +interface NfcProvider { + /** + * Check if NFC passport reading is available on this device. + */ + fun isAvailable(): Boolean + + /** + * Scan a passport via NFC. + * @param passportNumber 9-character passport number (padded with '<') + * @param dateOfBirth YYMMDD format + * @param dateOfExpiry YYMMDD format + * @param onProgress Called with (stateIndex: Int, percent: Int, message: String) + * @param onComplete Called with (success: Boolean, jsonResult: String) + * jsonResult contains PassportScanResult-compatible JSON on success, error message on failure. + */ + fun scanPassport( + passportNumber: String, + dateOfBirth: String, + dateOfExpiry: String, + onProgress: (stateIndex: Int, percent: Int, message: String) -> Unit, + onComplete: (success: Boolean, jsonResult: String) -> Unit, + ) + + /** + * Cancel any in-progress scan. + */ + fun cancelScan() +} +``` + +#### `BiometricProvider.kt` + +```kotlin +package xyz.self.sdk.providers + +interface BiometricProvider { + fun isAvailable(): Boolean + fun getBiometryType(): String // "faceId", "touchId", or "none" + fun authenticate( + reason: String, + onResult: (success: Boolean, error: String?) -> Unit, + ) +} +``` + +#### `SecureStorageProvider.kt` + +```kotlin +package xyz.self.sdk.providers + +interface SecureStorageProvider { + fun get(key: String): String? + fun set(key: String, value: String) + fun remove(key: String) +} +``` + +#### `CryptoProvider.kt` + +```kotlin +package xyz.self.sdk.providers + +interface CryptoProvider { + fun generateKey(keyRef: String) + fun getPublicKey(keyRef: String): String? // Base64-encoded public key + fun sign(keyRef: String, data: String): String? // Base64-encoded signature + fun deleteKey(keyRef: String) +} +``` + +#### `CameraMrzProvider.kt` + +```kotlin +package xyz.self.sdk.providers + +import platform.UIKit.UIView + +interface CameraMrzProvider { + fun isAvailable(): Boolean + fun createCameraView( + onMrzDetected: (jsonResult: String) -> Unit, + onProgress: (stateIndex: Int) -> Unit, + onError: (error: String) -> Unit, + ): UIView + fun stopCamera() +} +``` + +#### `HapticProvider.kt` + +```kotlin +package xyz.self.sdk.providers + +interface HapticProvider { + fun impact(style: String) // "light", "medium", "heavy" + fun notification(type: String) // "success", "warning", "error" + fun selection() +} +``` + +#### `DocumentsProvider.kt` + +```kotlin +package xyz.self.sdk.providers + +interface DocumentsProvider { + fun get(key: String): String? + fun set(key: String, value: String) + fun remove(key: String) + fun list(): List +} +``` + +#### `WebViewProvider.kt` + +```kotlin +package xyz.self.sdk.providers + +import platform.UIKit.UIView +import platform.UIKit.UIViewController + +interface WebViewProvider { + /** + * Create a WKWebView configured for the SDK bridge. + * @param onMessageReceived Called when WebView sends a bridge message (raw JSON string) + * @param isDebugMode If true, load from localhost dev server + * @return The WKWebView as UIView + */ + fun createWebView( + onMessageReceived: (String) -> Unit, + isDebugMode: Boolean, + ): UIView + + /** + * Evaluate JavaScript in the WebView. + */ + fun evaluateJs(js: String) + + /** + * Get a UIViewController that wraps the WebView for modal presentation. + */ + fun getViewController(): UIViewController +} +``` + +### Step 2: Swift Companion Package Skeleton + +#### `packages/self-sdk-swift/Package.swift` + +```swift +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "SelfSdkSwift", + platforms: [.iOS(.v15)], + products: [ + .library(name: "SelfSdkSwift", targets: ["SelfSdkSwift"]), + ], + dependencies: [ + .package(url: "https://github.com/AcroMace/NFCPassportReader", branch: "main"), + ], + targets: [ + .target( + name: "SelfSdkSwift", + dependencies: ["NFCPassportReader"], + path: "Sources/SelfSdkSwift" + ), + ] +) +``` + +#### `SelfSdkSwift.swift` — Public Setup API + +```swift +import Foundation +import SelfSdk // KMP XCFramework + +public class SelfSdkSwift { + /// Call this at app startup to register all default Swift provider implementations. + /// After calling this, SelfSdk.launch() will work on iOS. + public static func configure() { + let registry = SdkProviderRegistry.shared + registry.nfc = NfcProviderImpl() + registry.biometric = BiometricProviderImpl() + registry.secureStorage = SecureStorageProviderImpl() + registry.crypto = CryptoProviderImpl() + registry.cameraMrz = CameraMrzProviderImpl() + registry.haptic = HapticProviderImpl() + registry.documents = DocumentsProviderImpl() + registry.webView = WebViewProviderImpl() + } +} +``` + +### Step 3: Update `SelfSdk.ios.kt` + +Update the `launch()` method to check `SdkProviderRegistry.isConfigured()` and throw a clear error if not: + +```kotlin +actual fun launch(request: VerificationRequest, callback: SelfSdkCallback) { + check(SdkProviderRegistry.isConfigured()) { + "SelfSdk iOS requires Swift providers. Call SelfSdkSwift.configure() at app startup. " + + "See: https://docs.self.xyz/sdk/ios-setup" + } + // ... proceed with WebView launch using registered providers +} +``` + +### Validation + +- `./gradlew :shared:compileKotlinIosArm64` compiles (no cinterop needed for interfaces) +- Swift companion package skeleton builds with `swift build` +- Provider interfaces are visible from Swift via XCFramework exports + +--- + +## Chunk 3B: Biometric, SecureStorage, Haptic Handlers + +**Goal**: Implement the 3 simplest handlers end-to-end (Kotlin handler + Swift provider). + +### Biometric Handler (Kotlin side) + +Rewrite `iosMain/handlers/BiometricBridgeHandler.kt` to delegate to provider: + +```kotlin +class BiometricBridgeHandler : BridgeHandler { + override val domain = BridgeDomain.BIOMETRICS + + override suspend fun handle(method: String, params: Map): JsonElement? { + val provider = SdkProviderRegistry.biometric + ?: throw BridgeHandlerException("NOT_CONFIGURED", "Biometric provider not registered") + + return when (method) { + "authenticate" -> authenticate(provider, params) + "isAvailable" -> JsonPrimitive(provider.isAvailable()) + "getBiometryType" -> JsonPrimitive(provider.getBiometryType()) + else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown biometrics method: $method") + } + } + + private suspend fun authenticate(provider: BiometricProvider, params: Map): JsonElement { + val reason = params["reason"]?.jsonPrimitive?.content ?: "Authenticate" + return suspendCancellableCoroutine { cont -> + provider.authenticate(reason) { success, error -> + if (success) { + cont.resume(JsonPrimitive(true)) + } else { + cont.resumeWithException( + BridgeHandlerException("BIOMETRIC_ERROR", error ?: "Authentication failed") + ) + } + } + } + } +} +``` + +### Biometric Provider (Swift side) + +```swift +// Sources/SelfSdkSwift/Providers/BiometricProviderImpl.swift +import LocalAuthentication +import SelfSdk + +class BiometricProviderImpl: NSObject, BiometricProvider { + func isAvailable() -> Bool { + let context = LAContext() + return context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) + } + + func getBiometryType() -> String { + let context = LAContext() + _ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil) + switch context.biometryType { + case .faceID: return "faceId" + case .touchID: return "touchId" + default: return "none" + } + } + + func authenticate(reason: String, onResult: @escaping (Bool, String?) -> Void) { + let context = LAContext() + context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, error in + DispatchQueue.main.async { + onResult(success, error?.localizedDescription) + } + } + } +} +``` + +### SecureStorage Handler (Kotlin side) + +```kotlin +class SecureStorageBridgeHandler : BridgeHandler { + override val domain = BridgeDomain.SECURE_STORAGE + + override suspend fun handle(method: String, params: Map): JsonElement? { + val provider = SdkProviderRegistry.secureStorage + ?: throw BridgeHandlerException("NOT_CONFIGURED", "SecureStorage provider not registered") + + val key = params["key"]?.jsonPrimitive?.content + ?: throw BridgeHandlerException("MISSING_KEY", "Key parameter required") + + return when (method) { + "get" -> { + val value = provider.get(key) + if (value != null) JsonPrimitive(value) else JsonNull + } + "set" -> { + val value = params["value"]?.jsonPrimitive?.content + ?: throw BridgeHandlerException("MISSING_VALUE", "Value parameter required") + provider.set(key, value) + null + } + "remove" -> { + provider.remove(key) + null + } + else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown secureStorage method: $method") + } + } +} +``` + +### SecureStorage Provider (Swift side) + +```swift +// Sources/SelfSdkSwift/Providers/SecureStorageProviderImpl.swift +import Security +import SelfSdk + +class SecureStorageProviderImpl: NSObject, SecureStorageProvider { + private let service = "xyz.self.sdk" + + func get(key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + func set(key: String, value: String) { + // Delete existing first (upsert pattern) + remove(key: key) + guard let data = value.data(using: .utf8) else { return } + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly, + ] + SecItemAdd(query as CFDictionary, nil) + } + + func remove(key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + ] + SecItemDelete(query as CFDictionary) + } +} +``` + +### Haptic Handler (Kotlin side) + +```kotlin +class HapticBridgeHandler : BridgeHandler { + override val domain = BridgeDomain.HAPTIC + + override suspend fun handle(method: String, params: Map): JsonElement? { + val provider = SdkProviderRegistry.haptic + // Haptic is optional — silently no-op if not registered + provider ?: return null + + when (method) { + "impact" -> { + val style = params["style"]?.jsonPrimitive?.content ?: "medium" + provider.impact(style) + } + "notification" -> { + val type = params["type"]?.jsonPrimitive?.content ?: "success" + provider.notification(type) + } + "selection" -> provider.selection() + } + return null + } +} +``` + +### Haptic Provider (Swift side) + +```swift +// Sources/SelfSdkSwift/Providers/HapticProviderImpl.swift +import UIKit +import SelfSdk + +class HapticProviderImpl: NSObject, HapticProvider { + func impact(style: String) { + let uiStyle: UIImpactFeedbackGenerator.FeedbackStyle = switch style { + case "light": .light + case "heavy": .heavy + default: .medium + } + UIImpactFeedbackGenerator(style: uiStyle).impactOccurred() + } + + func notification(type: String) { + let uiType: UINotificationFeedbackGenerator.FeedbackType = switch type { + case "warning": .warning + case "error": .error + default: .success + } + UINotificationFeedbackGenerator().notificationOccurred(uiType) + } + + func selection() { + UISelectionFeedbackGenerator().selectionChanged() + } +} +``` + +### Validation + +- Biometric: Test on physical device — authenticate with Face ID/Touch ID +- SecureStorage: Write → read → delete roundtrip +- Haptic: Trigger each feedback type, confirm device vibrates +- All 3 handlers compile with `./gradlew :shared:compileKotlinIosArm64` + +--- + +## Chunk 3C: Crypto, Documents, Analytics, Lifecycle Handlers + +**Goal**: Implement remaining non-hardware handlers. + +### Crypto Handler + +The Kotlin handler delegates signing, key generation, and public key retrieval to `CryptoProvider`. The Swift implementation uses Security framework's `SecKey` APIs. + +**Kotlin handler** (`CryptoBridgeHandler.kt`): Routes `sign`, `generateKey`, `getPublicKey`, `deleteKey` to provider. + +**Swift provider** (`CryptoProviderImpl.swift`): +- `generateKey`: `SecKeyCreateRandomKey` with `kSecAttrKeyTypeECSECPrimeRandom`, 256-bit, stored in Keychain with `keyRef` as label +- `getPublicKey`: `SecKeyCopyPublicKey` → `SecKeyCopyExternalRepresentation` → Base64 +- `sign`: `SecKeyCreateSignature` with `kSecKeyAlgorithmECDSASignatureMessageX962SHA256` → Base64 +- `deleteKey`: `SecItemDelete` with key reference query + +### Documents Handler + +**Kotlin handler** (`DocumentsBridgeHandler.kt`): Routes `get`, `set`, `remove`, `list` to provider. + +**Swift provider** (`DocumentsProviderImpl.swift`): +- Uses `FileManager` with encrypted container directory at `Application Support/xyz.self.sdk/documents/` +- Each document stored as a file with the key as filename +- File protection: `.completeUntilFirstUserAuthentication` +- `list()`: Returns directory listing of document keys + +### Analytics Handler + +**No changes needed.** Stays as fire-and-forget — accepts all events, returns `null`. Optionally logs via `NSLog` for debug builds. + +### Lifecycle Handler + +**Kotlin handler** (`LifecycleBridgeHandler.kt`): +- `ready`: No-op, returns `null` +- `dismiss`: Calls `SdkProviderRegistry.webView?.getViewController()?.dismiss(animated: true, completion: nil)` — requires reference to the presenting view controller +- `setResult`: Parses success/failure, invokes the pending `SelfSdkCallback`, then dismisses + +**Design**: The lifecycle handler needs a reference to the `SelfSdkCallback` that was passed to `SelfSdk.launch()`. Add a `pendingCallback` property to the handler that `SelfSdk.ios.kt` sets before launching the WebView. + +```kotlin +class LifecycleBridgeHandler : BridgeHandler { + override val domain = BridgeDomain.LIFECYCLE + var pendingCallback: SelfSdkCallback? = null + var dismissAction: (() -> Unit)? = null + + override suspend fun handle(method: String, params: Map): JsonElement? { + return when (method) { + "ready" -> null + "dismiss" -> { + dismissAction?.invoke() + pendingCallback?.onCancelled() + null + } + "setResult" -> { + val success = params["success"]?.jsonPrimitive?.boolean ?: false + if (success) { + val data = params["data"] + pendingCallback?.onSuccess(parseVerificationResult(data)) + } else { + val code = params["errorCode"]?.jsonPrimitive?.content ?: "UNKNOWN" + val message = params["errorMessage"]?.jsonPrimitive?.content ?: "Unknown error" + pendingCallback?.onFailure(SelfSdkError(code, message)) + } + dismissAction?.invoke() + null + } + else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown lifecycle method: $method") + } + } +} +``` + +### Validation + +- Crypto: Generate key → get public key → sign data → verify signature roundtrip +- Documents: Store → retrieve → list → remove roundtrip +- Lifecycle: `setResult` delivers to callback, `dismiss` triggers `onCancelled` +- `./gradlew :shared:compileKotlinIosArm64` passes + +--- + +## Chunk 3D: iOS WebView Host + `SelfSdk.launch()` + +**Goal**: Get the full WebView-based verification flow working on iOS via Swift wrapper. + +### WebView Host (Kotlin side) + +Rewrite `IosWebViewHost.kt` to delegate to `WebViewProvider`: + +```kotlin +class IosWebViewHost( + private val router: MessageRouter, + private val isDebugMode: Boolean = false, +) { + private val provider: WebViewProvider + get() = SdkProviderRegistry.webView + ?: throw IllegalStateException("WebView provider not registered") + + fun createWebView(): Any { + return provider.createWebView( + onMessageReceived = { json -> router.onMessageReceived(json) }, + isDebugMode = isDebugMode, + ) + } + + fun evaluateJs(js: String) { + provider.evaluateJs(js) + } + + fun getViewController(): Any { + return provider.getViewController() + } +} +``` + +### WebView Provider (Swift side) + +```swift +// Sources/SelfSdkSwift/Providers/WebViewProviderImpl.swift +import WebKit +import UIKit +import SelfSdk + +class WebViewProviderImpl: NSObject, WebViewProvider, WKScriptMessageHandler { + private var webView: WKWebView? + private var viewController: UIViewController? + private var onMessageReceived: ((String) -> Void)? + + func createWebView(onMessageReceived: @escaping (String) -> Void, isDebugMode: Bool) -> UIView { + self.onMessageReceived = onMessageReceived + + let config = WKWebViewConfiguration() + config.userContentController.add(self, name: "SelfNativeIOS") + + let wv = WKWebView(frame: .zero, configuration: config) + wv.scrollView.isScrollEnabled = true + self.webView = wv + + if isDebugMode { + wv.load(URLRequest(url: URL(string: "http://localhost:5173")!)) + } else { + // Load bundled HTML from framework resources + if let bundleUrl = Bundle.main.url(forResource: "self-wallet/index", withExtension: "html") { + wv.loadFileURL(bundleUrl, allowingReadAccessTo: bundleUrl.deletingLastPathComponent()) + } + } + + return wv + } + + func evaluateJs(js: String) { + DispatchQueue.main.async { [weak self] in + self?.webView?.evaluateJavaScript(js, completionHandler: nil) + } + } + + func getViewController() -> UIViewController { + if let existing = viewController { return existing } + let vc = UIViewController() + if let wv = webView { + vc.view = wv + } + self.viewController = vc + return vc + } + + // WKScriptMessageHandler + func userContentController(_ controller: WKUserContentController, + didReceive message: WKScriptMessage) { + guard let body = message.body as? String else { return } + onMessageReceived?(body) + } +} +``` + +### `SelfSdk.ios.kt` — Launch Flow + +```kotlin +actual fun launch(request: VerificationRequest, callback: SelfSdkCallback) { + check(SdkProviderRegistry.isConfigured()) { + "iOS requires Swift providers. Call SelfSdkSwift.configure() at app startup." + } + + val router = MessageRouter( + sendToWebView = { js -> webViewHost?.evaluateJs(js) } + ) + + // Register all handlers + val lifecycleHandler = LifecycleBridgeHandler().apply { + pendingCallback = callback + dismissAction = { + // Dismiss the presented view controller + val vc = SdkProviderRegistry.webView?.getViewController() + vc?.dismiss(animated = true, completion = null) + } + } + + router.register(BiometricBridgeHandler()) + router.register(SecureStorageBridgeHandler()) + router.register(CryptoBridgeHandler()) + router.register(HapticBridgeHandler()) + router.register(AnalyticsBridgeHandler()) + router.register(lifecycleHandler) + router.register(DocumentsBridgeHandler()) + router.register(CameraMrzBridgeHandler()) + router.register(NfcBridgeHandler(router)) + + // Create WebView + webViewHost = IosWebViewHost(router, config.debug) + webViewHost?.createWebView() + + // Present modally + val sdkViewController = webViewHost?.getViewController() as UIViewController + sdkViewController.modalPresentationStyle = UIModalPresentationFullScreen + // Find the topmost view controller and present + findTopViewController()?.present(sdkViewController, animated = true, completion = null) +} + +private fun findTopViewController(): UIViewController? { + var vc = UIApplication.sharedApplication.keyWindow?.rootViewController + while (vc?.presentedViewController != null) { + vc = vc?.presentedViewController + } + return vc +} +``` + +### Validation + +- Full verification flow: `SelfSdk.launch()` → WebView loads → bridge messages flow → result delivered via callback +- Test in test app: Replace Swift workarounds with `SelfSdkSwift.configure()` call +- WebView loads both in debug mode (localhost) and release mode (bundled assets) + +--- + +## Chunk 3E: Wire Up NFC + Camera + +**Goal**: Connect existing `NfcPassportHelper.swift` and `MrzCameraHelper.swift` to the SDK's factory pattern. + +### NFC Provider (Swift side) + +Move `NfcPassportHelper.swift` from `packages/kmp-test-app/iosApp/iosApp/` into `packages/self-sdk-swift/Sources/SelfSdkSwift/Helpers/`. The provider impl wraps it: + +```swift +// Sources/SelfSdkSwift/Providers/NfcProviderImpl.swift +import SelfSdk + +class NfcProviderImpl: NSObject, NfcProvider { + private var nfcHelper: NfcPassportHelper? + + func isAvailable() -> Bool { + return NfcPassportHelper.isNfcAvailable() + } + + func scanPassport(passportNumber: String, dateOfBirth: String, dateOfExpiry: String, + onProgress: @escaping (Int32, Int32, String) -> Void, + onComplete: @escaping (Bool, String) -> Void) { + let helper = NfcPassportHelper() + self.nfcHelper = helper // Retain during scan + + helper.scanPassport( + passportNumber: passportNumber, + dateOfBirth: dateOfBirth, + dateOfExpiry: dateOfExpiry, + progress: { stateIndex, percent, message in + DispatchQueue.main.async { + onProgress(Int32(stateIndex), Int32(percent), message) + } + }, + completion: { [weak self] success, jsonResult in + DispatchQueue.main.async { + onComplete(success, jsonResult) + self?.nfcHelper = nil // Release + } + } + ) + } + + func cancelScan() { + nfcHelper = nil // Releasing triggers cleanup + } +} +``` + +### NFC Handler (Kotlin side) + +```kotlin +class NfcBridgeHandler(private val router: MessageRouter) : BridgeHandler { + override val domain = BridgeDomain.NFC + + override suspend fun handle(method: String, params: Map): JsonElement? { + val provider = SdkProviderRegistry.nfc + ?: throw BridgeHandlerException("NOT_CONFIGURED", "NFC provider not registered") + + return when (method) { + "scan" -> scan(provider, params) + "cancelScan" -> { provider.cancelScan(); null } + "isSupported" -> JsonPrimitive(provider.isAvailable()) + else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown NFC method: $method") + } + } + + private suspend fun scan(provider: NfcProvider, params: Map): JsonElement { + val passportNumber = params["passportNumber"]?.jsonPrimitive?.content + ?: throw BridgeHandlerException("MISSING_PARAM", "passportNumber required") + val dateOfBirth = params["dateOfBirth"]?.jsonPrimitive?.content + ?: throw BridgeHandlerException("MISSING_PARAM", "dateOfBirth required") + val dateOfExpiry = params["dateOfExpiry"]?.jsonPrimitive?.content + ?: throw BridgeHandlerException("MISSING_PARAM", "dateOfExpiry required") + + return suspendCancellableCoroutine { cont -> + provider.scanPassport( + passportNumber = passportNumber, + dateOfBirth = dateOfBirth, + dateOfExpiry = dateOfExpiry, + onProgress = { stateIndex, percent, message -> + // Push progress events to WebView + router.pushEvent( + BridgeDomain.NFC, "scanProgress", + buildJsonObject { + put("stateIndex", stateIndex) + put("percent", percent) + put("message", message) + } + ) + }, + onComplete = { success, jsonResult -> + if (success) { + cont.resume(Json.parseToJsonElement(jsonResult)) + } else { + cont.resumeWithException( + BridgeHandlerException("NFC_SCAN_FAILED", jsonResult) + ) + } + } + ) + } + } +} +``` + +### Camera MRZ Provider (Swift side) + +Move `MrzCameraHelper.swift` into `packages/self-sdk-swift/Sources/SelfSdkSwift/Helpers/`. Wrap it: + +```swift +// Sources/SelfSdkSwift/Providers/CameraMrzProviderImpl.swift +import UIKit +import SelfSdk + +class CameraMrzProviderImpl: NSObject, CameraMrzProvider { + private var cameraHelper: MrzCameraHelper? + + func isAvailable() -> Bool { + return true // Camera availability checked at runtime by AVCaptureDevice + } + + func createCameraView(onMrzDetected: @escaping (String) -> Void, + onProgress: @escaping (Int32) -> Void, + onError: @escaping (String) -> Void) -> UIView { + let helper = MrzCameraHelper() + self.cameraHelper = helper + + let view = helper.createCameraPreviewView(frame: .zero) + + helper.scanMrzWithCallbacks( + progress: { stateIndex in + DispatchQueue.main.async { onProgress(Int32(stateIndex)) } + }, + completion: { success, jsonResult in + DispatchQueue.main.async { + if success { + onMrzDetected(jsonResult) + } else { + onError(jsonResult) + } + } + } + ) + helper.startCamera() + return view + } + + func stopCamera() { + cameraHelper?.stopCamera() + cameraHelper = nil + } +} +``` + +### Camera Handler (Kotlin side) + +```kotlin +class CameraMrzBridgeHandler : BridgeHandler { + override val domain = BridgeDomain.CAMERA + + override suspend fun handle(method: String, params: Map): JsonElement? { + val provider = SdkProviderRegistry.cameraMrz + ?: throw BridgeHandlerException("NOT_CONFIGURED", "Camera MRZ provider not registered") + + return when (method) { + "isAvailable" -> JsonPrimitive(provider.isAvailable()) + "scanMRZ" -> scanMrz(provider) + "stopCamera" -> { provider.stopCamera(); null } + else -> throw BridgeHandlerException("METHOD_NOT_FOUND", "Unknown camera method: $method") + } + } + + private suspend fun scanMrz(provider: CameraMrzProvider): JsonElement { + return suspendCancellableCoroutine { cont -> + provider.createCameraView( + onMrzDetected = { jsonResult -> + cont.resume(Json.parseToJsonElement(jsonResult)) + }, + onProgress = { _ -> /* Progress updates for UI */ }, + onError = { error -> + cont.resumeWithException( + BridgeHandlerException("MRZ_SCAN_FAILED", error) + ) + } + ) + } + } +} +``` + +### Migration from Test App + +After this chunk, update the test app to use `SelfSdkSwift.configure()` instead of the manual factory registrations: + +```swift +// BEFORE (test app iOSApp.swift): +init() { + MrzCameraFactoryImpl.register() + NfcScanFactoryImpl.register() +} + +// AFTER: +init() { + SelfSdkSwift.configure() +} +``` + +The test app's `NfcScanFactoryImpl.swift` and `MrzCameraFactoryImpl.swift` become unnecessary — delete them. The test app's `NfcPassportHelper.swift` and `MrzCameraHelper.swift` are now in the Swift companion package. + +### Validation + +- NFC: Full passport scan on physical device (uses same NfcPassportHelper code, just moved) +- Camera: MRZ detection works through SDK handler → provider → MrzCameraHelper +- Test app: Replace factory registrations with `SelfSdkSwift.configure()`, verify same behavior +- Full end-to-end: `SelfSdk.launch()` → WebView → NFC scan → result callback + +--- + +## Key Reference Files + +| File | Role | +|------|------| +| `packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/handlers/` | All 9 stub handlers (rewrite) | +| `packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/webview/IosWebViewHost.kt` | WebView stub (rewrite) | +| `packages/kmp-sdk/shared/src/iosMain/kotlin/xyz/self/sdk/api/SelfSdk.ios.kt` | Launch flow (update) | +| `packages/kmp-sdk/shared/build.gradle.kts` | cinterop disabled (keep disabled) | +| `packages/kmp-test-app/iosApp/iosApp/NfcPassportHelper.swift` | Move to Swift companion package | +| `packages/kmp-test-app/iosApp/iosApp/MrzCameraHelper.swift` | Move to Swift companion package | +| `packages/kmp-test-app/iosApp/iosApp/NfcScanFactoryImpl.swift` | Reference pattern, then delete | +| `packages/kmp-test-app/iosApp/iosApp/MrzCameraFactoryImpl.swift` | Reference pattern, then delete | +| `packages/kmp-sdk/shared/src/androidMain/kotlin/xyz/self/sdk/handlers/` | Android handlers (reference for method contracts) | + +--- + +## Testing + +### Per-Chunk Test Requirements + +**Chunk 3A (Factory Infrastructure)**: +- `./gradlew :shared:compileKotlinIosArm64` passes with all provider interfaces +- Swift companion package builds: `cd packages/self-sdk-swift && swift build` +- Provider interfaces are visible from Swift via XCFramework exports (manual check) + +**Chunk 3B (Biometric, SecureStorage, Haptic)**: +- Biometric: Physical device test — Face ID / Touch ID prompt appears, success callback fires +- Biometric: Simulator test — `isAvailable()` returns false gracefully +- SecureStorage: Roundtrip test — `set("key", "value")` → `get("key")` returns `"value"` → `remove("key")` → `get("key")` returns null +- SecureStorage: Persistence test — write, kill app, relaunch, read back +- SecureStorage: Overwrite test — `set("key", "a")` → `set("key", "b")` → `get("key")` returns `"b"` +- Haptic: Manual test — each feedback type triggers device vibration + +**Chunk 3C (Crypto, Documents, Analytics, Lifecycle)**: +- Crypto: `generateKey("testRef")` → `getPublicKey("testRef")` returns non-null base64 → `sign("testRef", data)` returns non-null signature → `deleteKey("testRef")` → `getPublicKey("testRef")` returns null +- Crypto: Generated key persists in Keychain across app restarts +- Documents: Same CRUD roundtrip as SecureStorage +- Documents: `list()` returns all stored document keys +- Lifecycle: `setResult` with success=true invokes `SelfSdkCallback.onSuccess` +- Lifecycle: `dismiss` invokes `SelfSdkCallback.onCancelled` and dismisses view controller + +**Chunk 3D (WebView Host + Launch)**: +- `SelfSdk.launch()` without `SelfSdkSwift.configure()` throws clear error message +- `SelfSdk.launch()` after `configure()` presents WebView modally +- WebView loads index.html (debug mode: localhost, release: bundled) +- Bridge messages flow: WebView sends request → handler processes → response returned to WebView +- `SelfSdkCallback.onSuccess` fires when verification completes + +**Chunk 3E (NFC + Camera)**: +- NFC: Physical device — full passport scan matches test app behavior (same JSON output) +- NFC: Progress callbacks fire in correct order (states 0–7) +- NFC: Cancel during scan doesn't crash +- Camera MRZ: Detects MRZ lines from passport page (states progress from 0 → 3) +- Camera MRZ: Parsed MRZ data contains valid documentNumber, dateOfBirth, dateOfExpiry +- Integration: `SelfSdkSwift.configure()` in test app replaces manual factory registrations with identical behavior + +### Bridge Handler Parity Tests + +For each of the 9 handlers, verify method parity with Android: +- Same methods supported (same `method` strings accepted) +- Same parameter names and types expected +- Same response JSON structure returned +- Same error codes for same failure conditions + +Write a shared test matrix in `commonTest` that defines the expected contract per domain, then verify both platforms conform. + +--- + +## Dependencies + +- **SPEC-KMP-SDK.md** chunks 2A–2C: Required (Android complete, bridge protocol defined) +- **SPEC-PROVING-CLIENT.md**: Independent (proving client lives in `commonMain`, not iOS-specific) +- **SPEC-MINIPAY-SAMPLE.md**: Depends on this spec for iOS SDK functionality diff --git a/specs/SPEC-PERSON2-KMP.md b/specs/SPEC-KMP-SDK.md similarity index 97% rename from specs/SPEC-PERSON2-KMP.md rename to specs/SPEC-KMP-SDK.md index 6e2e31318..01685f223 100644 --- a/specs/SPEC-PERSON2-KMP.md +++ b/specs/SPEC-KMP-SDK.md @@ -1,5 +1,20 @@ # Person 2: KMP SDK / Native Handlers — Implementation Spec +## Current Status + +| Chunk | Description | Status | +|-------|-------------|--------| +| 2A | KMP Setup + Bridge Protocol | ✅ Complete | +| 2B | Android WebView Host | ✅ Complete | +| 2C | Android Native Handlers | ✅ Complete (all 9) | +| 2D | iOS WebView Host + cinterop | ⚠️ Partial (cinterop blocked by Xcode SDK compatibility issues, stubs in place) | +| 2E | iOS Native Handlers | ❌ Not Done (all 9 handlers are stubs throwing `NotImplementedError`) | +| 2F | SDK Public API + Test App | ⚠️ Partial (Android works end-to-end, iOS uses Swift workarounds via factory pattern in test app) | + +> **Note:** Remaining iOS handler work has moved to [SPEC-IOS-HANDLERS.md](./SPEC-IOS-HANDLERS.md) — uses a Swift wrapper pattern instead of cinterop. The native proving client (for headless SDK use without WebView) is specified in [SPEC-PROVING-CLIENT.md](./SPEC-PROVING-CLIENT.md). A MiniPay sample app demonstrating the headless flow is in [SPEC-MINIPAY-SAMPLE.md](./SPEC-MINIPAY-SAMPLE.md). + +--- + ## Overview You are building the **native side** of the Self Mobile SDK. This means: diff --git a/specs/SPEC-MINIPAY-SAMPLE.md b/specs/SPEC-MINIPAY-SAMPLE.md new file mode 100644 index 000000000..567fd866b --- /dev/null +++ b/specs/SPEC-MINIPAY-SAMPLE.md @@ -0,0 +1,623 @@ +# MiniPay Sample App — Headless KMP SDK Demo + +## Overview + +A native Compose Multiplatform app demonstrating the **headless SDK flow** — no WebView. This is the reference implementation for integrating the Self KMP SDK into a crypto wallet (MiniPay) or any app that needs native proof generation. + +The app scans a passport, generates a zero-knowledge proof using the native `ProvingClient`, and displays the result — all without launching a WebView. + +**Prerequisites**: +- [SPEC-KMP-SDK.md](./SPEC-KMP-SDK.md) — Bridge protocol, common models +- [SPEC-IOS-HANDLERS.md](./SPEC-IOS-HANDLERS.md) — iOS native handlers (NFC, Camera via Swift providers) +- [SPEC-PROVING-CLIENT.md](./SPEC-PROVING-CLIENT.md) — Native proving client (`ProvingClient`) + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────┐ +│ MiniPay Sample App (Compose Multiplatform) │ +├─────────────────────────────────────────────┤ +│ Screens: │ +│ HomeScreen → QrScanScreen → │ +│ DocumentScanScreen → ProvingScreen → │ +│ ResultScreen │ +├─────────────────────────────────────────────┤ +│ ViewModel Layer: │ +│ MainViewModel (navigation + state) │ +├─────────────────────────────────────────────┤ +│ KMP SDK (Native APIs — no WebView): │ +│ NFC scan → ProvingClient.prove() → result │ +│ SecureStorage for secrets │ +│ Crypto for key management │ +└─────────────────────────────────────────────┘ +``` + +### Key Difference from Test App + +| | Test App (`kmp-test-app`) | MiniPay Sample | +|---|---|---| +| Proof generation | WebView (Person 1's Vite bundle) | Native `ProvingClient` | +| UI | Compose Multiplatform + WebView overlay | Pure Compose Multiplatform | +| SDK entry point | `SelfSdk.launch()` → WebView | `ProvingClient.prove()` → native | +| Use case | Full Self verification flow | Wallet integration demo | + +--- + +## Directory Structure + +``` +packages/kmp-minipay-sample/ + build.gradle.kts + composeApp/ + build.gradle.kts + src/ + commonMain/kotlin/xyz/self/minipay/ + App.kt # Root composable + navigation + MainViewModel.kt # App state management + screens/ + HomeScreen.kt # Landing screen with "Verify" button + QrScanScreen.kt # QR code scanner + DocumentScanScreen.kt # MRZ camera + NFC passport scan + ProvingScreen.kt # Proving progress UI + ResultScreen.kt # Success/failure display + models/ + AppState.kt # Navigation state + VerificationRequest.kt # Parsed QR code data + theme/ + Theme.kt # MiniPay-style theming + + androidMain/kotlin/xyz/self/minipay/ + MainActivity.kt # Android entry point + QrScannerAndroid.kt # CameraX QR scanner (expect/actual) + + iosMain/kotlin/xyz/self/minipay/ + MainViewController.kt # iOS entry point + QrScannerIos.kt # AVFoundation QR scanner (expect/actual) + + androidApp/ + build.gradle.kts + src/main/ + AndroidManifest.xml + java/.../MainApplication.kt + + iosApp/ + iosApp/ + iOSApp.swift # SwiftUI wrapper + ContentView.swift + iosApp.xcodeproj/ +``` + +--- + +## Screens + +### 1. HomeScreen + +**Purpose**: Landing page with verification status and "Verify Identity" button. + +**UI**: +- App title: "MiniPay" with Self branding +- Status card: Shows current verification state (unverified / verified / expired) +- "Verify Identity" button → navigates to QR scanner +- Previously verified proof summary (if any) + +**State**: +```kotlin +data class HomeState( + val isVerified: Boolean = false, + val lastProofDate: String? = null, + val verifiedClaims: Map? = null, +) +``` + +### 2. QrScanScreen + +**Purpose**: Scan a QR code containing a verification request URL. + +**QR Code Format**: The QR code encodes a URL with verification parameters: +``` +https://self.xyz/verify?scope=&endpoint=&endpointType= + &chainId=&userId=&disclosures=&version= + &userDefinedData=&selfDefinedData= +``` + +**UI**: +- Full-screen camera preview with QR code overlay +- "Scan a verification QR code" instruction text +- Cancel button to return to home + +**Platform Implementation**: +- Android: CameraX + ML Kit `BarcodeScanning` +- iOS: AVFoundation `AVCaptureMetadataOutput` with `.qr` metadata type + +```kotlin +// commonMain — expect declaration +expect class QrScanner { + fun startScanning(onQrDetected: (String) -> Unit, onError: (String) -> Unit) + fun stopScanning() +} +``` + +**QR Parsing**: +```kotlin +fun parseVerificationUrl(url: String): ProvingRequest { + val uri = Url(url) + return ProvingRequest( + circuitType = CircuitType.DISCLOSE, // QR codes are always disclosure requests + scope = uri.parameters["scope"], + endpoint = uri.parameters["endpoint"], + endpointType = EndpointType.valueOf(uri.parameters["endpointType"]?.uppercase() ?: "CELO"), + chainId = uri.parameters["chainId"]?.toIntOrNull(), + userId = uri.parameters["userId"], + disclosures = parseDisclosures(uri.parameters["disclosures"]), + version = uri.parameters["version"]?.toIntOrNull() ?: 1, + userDefinedData = uri.parameters["userDefinedData"] ?: "", + selfDefinedData = uri.parameters["selfDefinedData"] ?: "", + ) +} +``` + +### 3. DocumentScanScreen + +**Purpose**: Two-phase document scanning — MRZ camera detection, then NFC passport read. + +**Phase 1 — MRZ Camera Scan**: +- Camera preview focused on passport MRZ zone +- Visual overlay showing the MRZ detection region +- Progress states: NO_TEXT → TEXT_DETECTED → ONE_MRZ_LINE → TWO_MRZ_LINES +- Auto-transitions to Phase 2 when MRZ is detected + +**Phase 2 — NFC Passport Scan**: +- Instruction: "Hold your phone against the passport" +- Progress animation showing NFC scan states (0–7): + - 0: "Hold your phone near the passport" + - 1: "Passport detected..." + - 2: "Authenticating..." + - 3: "Reading passport data..." + - 4: "Reading security data..." + - 5: "Verifying passport..." + - 6: "Processing..." + - 7: "Scan complete!" +- Progress bar reflecting percentage +- Cancel button + +**SDK Integration**: +```kotlin +// Phase 1: MRZ detection via Camera bridge handler +val mrzResult = sdk.cameraMrz.scanMrz() +val mrzData = Json.decodeFromString(mrzResult) + +// Phase 2: NFC scan using MRZ data for BAC/PACE authentication +val scanResult = sdk.nfc.scanPassport( + passportNumber = mrzData.documentNumber, + dateOfBirth = mrzData.dateOfBirth, + dateOfExpiry = mrzData.dateOfExpiry, +) +``` + +On Android, the NFC handler uses JMRTD directly. On iOS, it calls through the Swift `NfcProvider` → `NfcPassportHelper`. + +### 4. ProvingScreen + +**Purpose**: Show proving progress as the native `ProvingClient` runs. + +**UI**: +- Stepper/progress indicator showing current state +- Each state maps to a user-friendly label: + - `FetchingData` → "Fetching verification data..." + - `ValidatingDocument` → "Validating your document..." + - `ConnectingTee` → "Connecting to secure enclave..." + - `Proving` → "Generating proof..." (with spinner) + - `PostProving` → "Finalizing..." +- Animated progress bar +- Cancel button (cancels the coroutine) + +**SDK Integration**: +```kotlin +val provingClient = ProvingClient(ProvingConfig( + environment = if (request.endpointType == EndpointType.STAGING_CELO) + Environment.STG else Environment.PROD, +)) + +// Load secret from secure storage +val secret = sdk.secureStorage.get("user_secret") + ?: throw IllegalStateException("No user secret found") + +// Load parsed document from previous scan +val document = parsePassportScanResult(scanResult) + +// Run proving with state callbacks +try { + val result = provingClient.prove( + document = document, + request = request, + secret = secret, + onStateChange = { state -> + // Update UI with current state + viewModel.updateProvingState(state) + }, + ) + viewModel.navigateToResult(result) +} catch (e: ProvingException) { + viewModel.navigateToResult(ProofResult(success = false), error = e) +} +``` + +### 5. ResultScreen + +**Purpose**: Display proof result — success or failure. + +**Success UI**: +- Checkmark animation +- "Identity Verified" title +- Disclosed claims list (name, nationality, age, etc. based on disclosure flags) +- Proof UUID for reference +- "Done" button → return to HomeScreen + +**Failure UI**: +- Error icon +- Error code and human-readable message +- "Try Again" button → return to appropriate screen +- Error-specific guidance: + - `DOCUMENT_NOT_SUPPORTED` → "Your passport type is not yet supported" + - `NOT_REGISTERED` → "Please register your passport first" + - `TEE_CONNECT_FAILED` → "Connection failed. Check your internet and try again" + - `PROVE_FAILED` → "Proof generation failed. Please try again" + +--- + +## ViewModel + +```kotlin +class MainViewModel { + // Navigation state + var currentScreen by mutableStateOf(Screen.Home) + + // Data passed between screens + var verificationRequest: ProvingRequest? = null + var mrzData: MrzData? = null + var passportScanResult: JsonElement? = null + var provingState: ProvingState? = null + var proofResult: ProofResult? = null + var error: ProvingException? = null + + // Navigation + fun navigateToQrScan() { currentScreen = Screen.QrScan } + fun onQrScanned(url: String) { + verificationRequest = parseVerificationUrl(url) + currentScreen = Screen.DocumentScan + } + fun onMrzDetected(data: MrzData) { mrzData = data } + fun onPassportScanned(result: JsonElement) { + passportScanResult = result + currentScreen = Screen.Proving + } + fun updateProvingState(state: ProvingState) { provingState = state } + fun navigateToResult(result: ProofResult, error: ProvingException? = null) { + proofResult = result + this.error = error + currentScreen = Screen.Result + } + fun returnToHome() { + currentScreen = Screen.Home + // Clear transient state + } +} + +sealed class Screen { + data object Home : Screen() + data object QrScan : Screen() + data object DocumentScan : Screen() + data object Proving : Screen() + data object Result : Screen() +} +``` + +--- + +## Registration Flow + +Before disclosure, the user must register their passport. The sample app detects this automatically: + +1. User scans QR code (disclosure request) +2. App loads passport data from secure storage (or scans if first time) +3. `ProvingClient.prove()` with `CircuitType.DISCLOSE` +4. `DocumentValidator` detects user is NOT registered +5. State machine throws `ProvingException("NOT_REGISTERED")` +6. App catches this and shows: "You need to register first. Register now?" +7. If yes: runs `ProvingClient.prove()` with `CircuitType.REGISTER` (and DSC if needed) +8. On success: re-runs the original disclosure request + +```kotlin +try { + val result = provingClient.prove(document, request, secret, onStateChange) + // Success — show result +} catch (e: ProvingException) { + if (e.code == "NOT_REGISTERED") { + // Auto-register flow + val registerRequest = ProvingRequest(circuitType = CircuitType.REGISTER) + provingClient.prove(document, registerRequest, secret, onStateChange) + // Retry original disclosure + val result = provingClient.prove(document, request, secret, onStateChange) + // Show result + } else { + // Show error + } +} +``` + +--- + +## Build Configuration + +### `packages/kmp-minipay-sample/build.gradle.kts` + +```kotlin +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} +``` + +### `composeApp/build.gradle.kts` + +```kotlin +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidApplication) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) + alias(libs.plugins.kotlinSerialization) +} + +kotlin { + androidTarget { + compilations.all { kotlinOptions { jvmTarget = "17" } } + } + iosArm64() + iosSimulatorArm64() + + listOf(iosArm64(), iosSimulatorArm64()).forEach { + it.binaries.framework { + baseName = "ComposeApp" + isStatic = true + } + } + + sourceSets { + commonMain.dependencies { + // Compose + implementation(compose.runtime) + implementation(compose.foundation) + implementation(compose.material3) + implementation(compose.ui) + implementation(compose.components.resources) + + // Navigation + implementation(libs.navigation.compose) + + // KMP SDK (local project dependency) + implementation(project(":kmp-sdk:shared")) + + // Serialization + implementation(libs.kotlinx.serialization.json) + implementation(libs.kotlinx.coroutines.core) + } + + val androidMain by getting { + dependencies { + implementation(libs.compose.ui.tooling.preview) + implementation(libs.androidx.activity.compose) + // QR scanning + implementation("com.google.mlkit:barcode-scanning:17.2.0") + implementation("androidx.camera:camera-camera2:1.3.4") + implementation("androidx.camera:camera-lifecycle:1.3.4") + implementation("androidx.camera:camera-view:1.3.4") + } + } + } +} + +android { + namespace = "xyz.self.minipay" + compileSdk = 35 + defaultConfig { + applicationId = "xyz.self.minipay" + minSdk = 24 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + } +} +``` + +--- + +## Chunking Guide + +### Chunk 5A: Project Setup + Navigation Shell + +**Goal**: Create the Compose Multiplatform project with navigation between empty screens. + +**Steps**: +1. Create `packages/kmp-minipay-sample/` directory structure +2. Configure `build.gradle.kts` with Compose Multiplatform + KMP SDK dependency +3. Implement `App.kt` with navigation controller +4. Implement `MainViewModel.kt` with screen state +5. Create placeholder screens (HomeScreen, QrScanScreen, DocumentScanScreen, ProvingScreen, ResultScreen) +6. Android: `MainActivity.kt`, `AndroidManifest.xml` (NFC + Camera permissions) +7. iOS: `MainViewController.kt`, `iOSApp.swift`, `ContentView.swift` +8. Validate: App builds and launches with navigation between placeholder screens + +### Chunk 5B: QR Scanner + +**Goal**: Camera-based QR code scanning with URL parsing. + +**Steps**: +1. Define `expect class QrScanner` in commonMain +2. Implement Android actual: CameraX + ML Kit BarcodeScanning +3. Implement iOS actual: AVFoundation metadata output (via Swift provider if needed) +4. Implement `QrScanScreen.kt` — camera preview with QR overlay +5. Implement URL parser: `parseVerificationUrl(url) → ProvingRequest` +6. Wire QR detection → ViewModel → navigate to DocumentScan +7. Validate: Scan a test QR code, verify parsed parameters + +### Chunk 5C: Document Scanner (MRZ + NFC) + +**Goal**: Passport scanning using SDK native APIs. + +**Steps**: +1. Implement `DocumentScanScreen.kt` with two-phase UI +2. Phase 1: Camera MRZ detection — use SDK's `CameraMrzBridgeHandler` via native API +3. Phase 2: NFC passport scan — use SDK's `NfcBridgeHandler` via native API +4. Progress UI: Map scan states to visual indicators +5. Parse `PassportScanResult` into `IDDocument` model for proving +6. Validate: Full MRZ detect → NFC scan on physical device + +**Note**: On Android, the NFC scan uses the SDK's `NfcBridgeHandler` directly (JMRTD). On iOS, it calls through the Swift provider chain. The sample app calls the handler APIs directly rather than going through the WebView bridge — this is the "headless" pattern. + +### Chunk 5D: Proving Screen + Integration + +**Goal**: Wire up `ProvingClient` and show progress. + +**Steps**: +1. Implement `ProvingScreen.kt` with state-based progress UI +2. Instantiate `ProvingClient` with config from parsed QR +3. Convert `PassportScanResult` → `IDDocument` (passport data model) +4. Load user secret from secure storage (or generate if first time) +5. Call `provingClient.prove()` with `onStateChange` callback +6. Handle success → navigate to ResultScreen +7. Handle registration requirement → auto-register flow +8. Handle errors → navigate to ResultScreen with error +9. Validate: Full end-to-end flow against staging TEE + +### Chunk 5E: Result Screen + Polish + +**Goal**: Display results and polish the app. + +**Steps**: +1. Implement `ResultScreen.kt` with success/failure UI +2. Display disclosed claims based on verification request +3. Persist verification status for HomeScreen +4. Theme: MiniPay-style colors and typography +5. Error handling: User-friendly messages for each error code +6. iOS: Wire up `SelfSdkSwift.configure()` in `iOSApp.swift` +7. Validate: Full flow on both platforms, error cases handled + +--- + +## Key SDK APIs Used + +The sample app demonstrates calling SDK APIs directly (no WebView bridge): + +```kotlin +// 1. MRZ Camera Scan +val cameraMrzProvider = SdkProviderRegistry.cameraMrz // iOS +// or direct call to CameraMrzBridgeHandler // Android + +// 2. NFC Passport Scan +val nfcProvider = SdkProviderRegistry.nfc // iOS +// or direct call to NfcBridgeHandler // Android + +// 3. Secure Storage (for user secret) +val storageProvider = SdkProviderRegistry.secureStorage // iOS +// or direct call to SecureStorageBridgeHandler // Android + +// 4. Native Proving +val provingClient = ProvingClient(config) +val result = provingClient.prove(document, request, secret, onStateChange) +``` + +For a cleaner API, the SDK should expose a unified interface in `commonMain`: + +```kotlin +// Future enhancement: SelfSdk headless API +class SelfSdk { + val nfc: NfcApi // Wraps NfcBridgeHandler / NfcProvider + val camera: CameraApi // Wraps CameraMrzBridgeHandler / CameraMrzProvider + val storage: StorageApi // Wraps SecureStorageBridgeHandler / SecureStorageProvider + val proving: ProvingClient +} +``` + +--- + +## Testing + +### Unit Tests (`commonTest/`) + +**QR URL Parsing** (~8 tests): +- `parseVerificationUrl()` extracts all parameters correctly +- Missing optional parameters use defaults +- Malformed URL throws with clear error +- URL with encoded characters decodes correctly +- `disclosures` JSON parameter parses into `Disclosures` object + +**ViewModel Navigation** (~6 tests): +- Initial screen is `Home` +- `onQrScanned()` parses URL and navigates to `DocumentScan` +- `onPassportScanned()` navigates to `Proving` +- `navigateToResult()` stores result and navigates to `Result` +- `returnToHome()` clears transient state + +### Device Tests (manual, per-chunk) + +**Chunk 5A — Navigation Shell**: +- App launches on Android emulator and iOS simulator +- All 5 screens reachable via navigation +- Back navigation works correctly + +**Chunk 5B — QR Scanner**: +- Camera permission prompt appears on first launch +- Camera preview renders full-screen +- Scanning a test QR code extracts correct URL +- Scanning a non-URL QR code shows error gracefully +- Cancel returns to home + +**Chunk 5C — Document Scanner**: +- MRZ camera phase: Progress states advance as passport is positioned (0 → 1 → 2 → 3) +- MRZ camera phase: Auto-transitions to NFC phase when MRZ detected +- NFC phase: Progress states advance during passport scan (0 → 7) +- NFC phase: Cancel during scan returns to previous screen without crash +- NFC phase: Bad MRZ data (wrong dates) produces clear error +- Full scan produces valid `PassportScanResult` JSON + +**Chunk 5D — Proving**: +- State callbacks fire in order: FetchingData → ValidatingDocument → ConnectingTee → Proving → PostProving → Completed +- UI updates for each state transition (progress indicator advances) +- Cancel during proving cancels the coroutine cleanly +- NOT_REGISTERED error triggers auto-register flow +- Other errors navigate to result screen with error details +- Full end-to-end against staging TEE succeeds with mock passport + +**Chunk 5E — Result + Polish**: +- Success screen shows disclosed claims matching the request's disclosures +- Failure screen shows error code and human-readable message +- "Try Again" navigates back to appropriate screen +- "Done" returns to home, home shows verified status +- Both platforms: identical behavior for same QR code + passport combo + +### End-to-End Acceptance Test + +1. Launch app → Home screen shows "Unverified" +2. Tap "Verify Identity" → QR scanner opens +3. Scan test QR code → navigates to document scanner +4. Position passport → MRZ detected → "Hold phone near passport" +5. Tap passport → NFC scan completes +6. Proving screen shows progress through all states +7. Result screen shows "Identity Verified" with correct claims +8. Return to Home → shows "Verified" with proof date + +Run on: Android physical device + iOS physical device. + +--- + +## Dependencies + +- **SPEC-KMP-SDK.md**: NFC handler (Android), Camera handler (Android), SecureStorage handler +- **SPEC-IOS-HANDLERS.md**: NFC provider (iOS), Camera provider (iOS), SecureStorage provider (iOS) +- **SPEC-PROVING-CLIENT.md**: `ProvingClient` API — the core of this app +- **SPEC-COMMON-LIB.md**: Passport data parsing, commitment generation (used by ProvingClient) diff --git a/specs/SPEC.md b/specs/SPEC-OVERVIEW.md similarity index 95% rename from specs/SPEC.md rename to specs/SPEC-OVERVIEW.md index 3bc9f0f9d..52d6944ce 100644 --- a/specs/SPEC.md +++ b/specs/SPEC-OVERVIEW.md @@ -49,9 +49,13 @@ MiniPay expects a single Kotlin Multiplatform interface that works on both iOS a | **Person 1** | UI + WebView + Bridge JS | `@selfxyz/webview-bridge` (npm), `@selfxyz/webview-app` (Vite bundle) | | **Person 2** | KMP SDK + Native Handlers + Test App | `packages/kmp-sdk/` → AAR + XCFramework, test app | -Each person has their own detailed spec: -- [SPEC-PERSON1-UI.md](./SPEC-PERSON1-UI.md) — UI / WebView / Bridge JS -- [SPEC-PERSON2-KMP.md](./SPEC-PERSON2-KMP.md) — KMP SDK / Native Handlers +Detailed specs: +- [SPEC-WEBVIEW-UI.md](./SPEC-WEBVIEW-UI.md) — UI / WebView / Bridge JS +- [SPEC-KMP-SDK.md](./SPEC-KMP-SDK.md) — KMP SDK / Native Handlers (Android complete, iOS stubs) +- [SPEC-IOS-HANDLERS.md](./SPEC-IOS-HANDLERS.md) — iOS handlers via Swift wrapper pattern +- [SPEC-COMMON-LIB.md](./SPEC-COMMON-LIB.md) — Pure Kotlin common library (Poseidon, trees, parsing) +- [SPEC-PROVING-CLIENT.md](./SPEC-PROVING-CLIENT.md) — Native proving client (headless, no WebView) +- [SPEC-MINIPAY-SAMPLE.md](./SPEC-MINIPAY-SAMPLE.md) — MiniPay sample app (headless demo) --- diff --git a/specs/SPEC-PROVING-CLIENT.md b/specs/SPEC-PROVING-CLIENT.md new file mode 100644 index 000000000..0c5979802 --- /dev/null +++ b/specs/SPEC-PROVING-CLIENT.md @@ -0,0 +1,1737 @@ +# Native Proving Client — KMP `commonMain` Implementation Spec + +## Overview + +Port the TypeScript proving machine (`packages/mobile-sdk-alpha/src/proving/provingMachine.ts`) to native Kotlin in `commonMain`, enabling headless proof generation without a WebView. This is the foundation for: + +1. **MiniPay integration** — crypto wallet needs native SDK, no WebView for sensitive operations +2. **Browser extension** — adding `jsMain` or `wasmMain` targets later gives the same proving logic for free + +The proving client lives entirely in `commonMain` so it works on Android, iOS, and future JS/WASM targets. + +**Prerequisites**: [SPEC-KMP-SDK.md](./SPEC-KMP-SDK.md) (bridge protocol, common models). + +--- + +## Architecture + +### Current TypeScript Stack + +``` +provingMachine.ts (XState state machine + Zustand store) + → Circuit input generators (generateTEEInputs*) + → TEE WebSocket connection (openpassport_hello, attestation, submit) + → ECDH key exchange + AES-256-GCM encryption + → Socket.IO status polling (queued → processing → success/failure) + → Protocol data fetching (trees, circuits, DNS mapping) + → Document validation (commitment lookup, nullifier check, DSC-in-tree) +``` + +### Target Kotlin Structure + +``` +packages/kmp-sdk/shared/src/commonMain/kotlin/xyz/self/sdk/ + proving/ + ProvingClient.kt — Public API: prove(passportData, request) → ProofResult + ProvingStateMachine.kt — State machine (sealed class states, coroutine-based) + CircuitInputGenerator.kt — Generate TEE circuit inputs from passport data + CircuitNameResolver.kt — Map document metadata to circuit names + TeeConnection.kt — WebSocket client for TEE prover (JSON-RPC) + TeeAttestation.kt — JWT attestation validation, PCR0 mapping + PayloadEncryption.kt — ECDH P-256 + AES-256-GCM + ProtocolDataStore.kt — Fetch/cache DSC trees, CSCA trees, circuits, commitment trees + DocumentValidator.kt — Check registration, nullification, DSC-in-tree + StatusListener.kt — Socket.IO status polling for proof completion + models/ + ProofResult.kt — Proof UUID, status, claims + CircuitType.kt — dsc, register, disclose + DocumentCategory.kt — passport, id_card, aadhaar, kyc + TeeMessage.kt — WebSocket JSON-RPC message types + ProtocolData.kt — Trees, circuits, DNS mapping models + PayloadModels.kt — TEEPayload, TEEPayloadDisclose, EncryptedPayload + ProvingState.kt — State machine states (sealed class) + EndpointType.kt — celo, staging_celo, https +``` + +--- + +## Module Dependencies + +### `build.gradle.kts` additions (commonMain) + +```kotlin +commonMain.dependencies { + // Existing + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + + // NEW — HTTP client (KMP-compatible) + implementation("io.ktor:ktor-client-core:3.0.3") + implementation("io.ktor:ktor-client-content-negotiation:3.0.3") + implementation("io.ktor:ktor-serialization-kotlinx-json:3.0.3") + + // NEW — WebSocket support + implementation("io.ktor:ktor-client-websockets:3.0.3") +} + +val androidMain by getting { + dependencies { + // Ktor engine + implementation("io.ktor:ktor-client-okhttp:3.0.3") + // Crypto (BouncyCastle already present for NFC) + implementation("org.bouncycastle:bcprov-jdk18on:1.78.1") + } +} + +val iosMain by getting { + dependencies { + // Ktor engine + implementation("io.ktor:ktor-client-darwin:3.0.3") + } +} +``` + +### Platform-specific Crypto + +| Operation | Android | iOS | +|-----------|---------|-----| +| ECDH P-256 | BouncyCastle `ECDHBasicAgreement` | `SecKeyCreateRandomKey` + `SecKeyCopyKeyExchangeResult` | +| AES-256-GCM | `javax.crypto.Cipher` | `CCCryptorGCM` (CommonCrypto) | +| SHA-256 | `java.security.MessageDigest` | `CC_SHA256` (CommonCrypto) | +| RSA verify | `java.security.Signature` | `SecKeyVerifySignature` | +| X.509 parse | `java.security.cert.CertificateFactory` | `SecCertificateCreateWithData` | +| Random bytes | `java.security.SecureRandom` | `SecRandomCopyBytes` | + +Define `expect`/`actual` for these in a `crypto/` package: + +```kotlin +// commonMain +expect object PlatformCrypto { + fun generateEcdhKeyPair(): EcdhKeyPair + fun deriveSharedSecret(privateKey: ByteArray, peerPublicKey: ByteArray): ByteArray + fun encryptAesGcm(plaintext: ByteArray, key: ByteArray, iv: ByteArray): AesGcmResult + fun sha256(data: ByteArray): ByteArray + fun verifyRsaSha256(publicKey: ByteArray, data: ByteArray, signature: ByteArray): Boolean + fun randomBytes(size: Int): ByteArray + fun parseX509Certificate(pem: String): X509CertificateInfo +} + +data class EcdhKeyPair(val publicKey: ByteArray, val privateKey: ByteArray) +data class AesGcmResult(val ciphertext: ByteArray, val authTag: ByteArray) +data class X509CertificateInfo( + val subjectCN: String?, + val issuerCN: String?, + val publicKeyBytes: ByteArray, + val signatureBytes: ByteArray, + val encoded: ByteArray, +) +``` + +--- + +## Detailed Component Specs + +### 1. `ProvingClient.kt` — Public API + +The main entry point for headless proof generation. Replaces the need for a WebView. + +```kotlin +package xyz.self.sdk.proving + +import xyz.self.sdk.models.* + +/** + * Native proving client — generates zero-knowledge proofs without a WebView. + * + * Usage: + * val client = ProvingClient(config) + * val result = client.prove(passportData, request) + */ +class ProvingClient( + private val config: ProvingConfig, +) { + private val protocolStore = ProtocolDataStore(config) + private val stateMachine = ProvingStateMachine() + + /** + * Run the full proving flow: fetch data → validate → connect TEE → prove → return result. + * + * @param document Parsed passport/ID document from NFC scan + * @param request Verification request parameters (scope, disclosures, endpoint) + * @param secret User's secret key (loaded from secure storage) + * @param onStateChange Optional callback for UI progress updates + * @return ProofResult on success + * @throws ProvingException on failure + */ + suspend fun prove( + document: IDDocument, + request: ProvingRequest, + secret: String, + onStateChange: ((ProvingState) -> Unit)? = null, + ): ProofResult { + return stateMachine.run( + document = document, + request = request, + secret = secret, + protocolStore = protocolStore, + config = config, + onStateChange = onStateChange, + ) + } +} + +data class ProvingConfig( + val environment: Environment = Environment.PROD, + val debug: Boolean = false, +) + +enum class Environment { + PROD, STG; + + val apiBaseUrl: String get() = when (this) { + PROD -> "https://api.self.xyz" + STG -> "https://api.staging.self.xyz" + } + + val wsRelayerUrl: String get() = when (this) { + PROD -> "wss://websocket.self.xyz" + STG -> "wss://websocket.staging.self.xyz" + } +} +``` + +### 2. `ProvingStateMachine.kt` — State Machine + +Port of the XState machine. Uses Kotlin sealed classes instead of XState/Zustand. + +#### State Definitions + +```kotlin +sealed class ProvingState { + data object Idle : ProvingState() + data object ParsingDocument : ProvingState() // DSC circuit only + data object FetchingData : ProvingState() + data object ValidatingDocument : ProvingState() + data class ConnectingTee(val attempt: Int = 1) : ProvingState() + data object ReadyToProve : ProvingState() + data object Proving : ProvingState() + data object PostProving : ProvingState() + data class Completed(val result: ProofResult) : ProvingState() + data class Error(val code: String, val reason: String) : ProvingState() + data class DocumentNotSupported(val details: String) : ProvingState() + data class AlreadyRegistered(val csca: String?) : ProvingState() + data class AccountRecoveryChoice(val nullifier: String) : ProvingState() + data class PassportDataNotFound(val details: String) : ProvingState() +} +``` + +#### State Machine Runner + +```kotlin +class ProvingStateMachine { + private var currentState: ProvingState = ProvingState.Idle + + suspend fun run( + document: IDDocument, + request: ProvingRequest, + secret: String, + protocolStore: ProtocolDataStore, + config: ProvingConfig, + onStateChange: ((ProvingState) -> Unit)?, + ): ProofResult { + fun transition(state: ProvingState) { + currentState = state + onStateChange?.invoke(state) + } + + try { + // Step 1: Determine circuit type sequence + val circuitSequence = determineCircuitSequence(document, request) + + for (circuitType in circuitSequence) { + // Step 2: Fetch protocol data + transition(ProvingState.FetchingData) + val protocolData = protocolStore.fetchAll( + environment = config.environment, + documentCategory = document.documentCategory, + dscSki = document.dscAuthorityKeyIdentifier, + ) + + // Step 3: Validate document + transition(ProvingState.ValidatingDocument) + val validation = DocumentValidator.validate( + document = document, + secret = secret, + circuitType = circuitType, + protocolData = protocolData, + environment = config.environment, + ) + when (validation) { + is ValidationResult.Supported -> { /* continue */ } + is ValidationResult.AlreadyRegistered -> { + transition(ProvingState.AlreadyRegistered(validation.csca)) + continue // Skip to next circuit in sequence + } + is ValidationResult.NotSupported -> { + transition(ProvingState.DocumentNotSupported(validation.details)) + throw ProvingException("DOCUMENT_NOT_SUPPORTED", validation.details) + } + is ValidationResult.NotRegistered -> { + transition(ProvingState.PassportDataNotFound(validation.details)) + throw ProvingException("NOT_REGISTERED", validation.details) + } + is ValidationResult.AccountRecovery -> { + transition(ProvingState.AccountRecoveryChoice(validation.nullifier)) + throw ProvingException("ACCOUNT_RECOVERY", "Document nullified, recovery needed") + } + } + + // Step 4: Connect to TEE + transition(ProvingState.ConnectingTee()) + val teeConnection = TeeConnection(config) + val session = teeConnection.connect( + circuitName = CircuitNameResolver.resolve(document, circuitType), + wsUrl = protocolData.resolveWebSocketUrl(circuitType, document), + onReconnect = { attempt -> transition(ProvingState.ConnectingTee(attempt)) }, + ) + + // Step 5: Generate inputs + encrypt + submit + transition(ProvingState.Proving) + val inputs = CircuitInputGenerator.generate( + document = document, + secret = secret, + circuitType = circuitType, + protocolData = protocolData, + request = request, + ) + val payload = PayloadBuilder.build(inputs, circuitType, document, request) + val encrypted = PayloadEncryption.encrypt( + payload = payload, + sharedKey = session.sharedKey, + ) + val proofUuid = teeConnection.submitProof(session, encrypted) + + // Step 6: Wait for result via Socket.IO + val status = StatusListener.awaitResult( + uuid = proofUuid, + environment = config.environment, + ) + + // Step 7: Post-proving + transition(ProvingState.PostProving) + if (status.isSuccess) { + if (circuitType == CircuitType.REGISTER) { + // Mark document as registered (caller should persist this) + } + } else { + throw ProvingException(status.errorCode ?: "PROVE_FAILED", status.reason ?: "Proof generation failed") + } + } + + val result = ProofResult(success = true, circuitType = circuitSequence.last()) + transition(ProvingState.Completed(result)) + return result + + } catch (e: ProvingException) { + transition(ProvingState.Error(e.code, e.message ?: "Unknown error")) + throw e + } + } + + /** + * Determine the sequence of circuits to prove. + * DSC flow: [DSC, REGISTER] + * Register flow: [REGISTER] + * Disclose flow: [DISCLOSE] + */ + private fun determineCircuitSequence(document: IDDocument, request: ProvingRequest): List { + return when (request.circuitType) { + CircuitType.DSC -> listOf(CircuitType.DSC, CircuitType.REGISTER) + CircuitType.REGISTER -> listOf(CircuitType.REGISTER) + CircuitType.DISCLOSE -> listOf(CircuitType.DISCLOSE) + } + } +} +``` + +**Key difference from TypeScript**: The TS version uses XState actor + Zustand store with event-driven transitions. The Kotlin version uses a linear `suspend fun` with structured concurrency — simpler, easier to test, and natural for Kotlin coroutines. + +--- + +### 3. `CircuitInputGenerator.kt` — Generate TEE Circuit Inputs + +Port of `generateTEEInputsRegister`, `generateTEEInputsDSC`, `generateTEEInputsDiscloseStateless`. + +```kotlin +object CircuitInputGenerator { + /** + * Generate circuit inputs based on document type and circuit type. + * + * @return Circuit inputs as a JsonElement tree (to be serialized and encrypted) + */ + suspend fun generate( + document: IDDocument, + secret: String, + circuitType: CircuitType, + protocolData: ProtocolData, + request: ProvingRequest, + ): CircuitInputs { + return when (circuitType) { + CircuitType.REGISTER -> generateRegisterInputs(document, secret, protocolData) + CircuitType.DSC -> generateDscInputs(document, protocolData) + CircuitType.DISCLOSE -> generateDiscloseInputs(document, secret, protocolData, request) + } + } +} +``` + +#### TypeScript Functions → Kotlin Equivalents + +| TypeScript Function | Kotlin Equivalent | Location | +|---|---|---| +| `generateTEEInputsRegister(secret, passportData, dscTree, env)` | `CircuitInputGenerator.generateRegisterInputs()` | `CircuitInputGenerator.kt` | +| `generateTEEInputsDSC(passportData, cscaTree, env)` | `CircuitInputGenerator.generateDscInputs()` | `CircuitInputGenerator.kt` | +| `generateTEEInputsDiscloseStateless(secret, passportData, selfApp, getTree)` | `CircuitInputGenerator.generateDiscloseInputs()` | `CircuitInputGenerator.kt` | +| `generateCircuitInputsRegister(secret, passportData, dscTree)` | Internal to `generateRegisterInputs` | Port from `common/src/utils/circuits/registerInputs.ts` | +| `generateCircuitInputsDSC(passportData, cscaTree)` | Internal to `generateDscInputs` | Port from `common/src/utils/circuits/registerInputs.ts` | +| `generateCircuitInputsVCandDisclose(...)` | Internal to `generateDiscloseInputs` | Port from `common/src/utils/circuits/registerInputs.ts` | +| `getSelectorDg1(documentCategory, disclosures)` | `SelectorGenerator.getDg1Selector()` | New helper | +| `generateCommitment(secret, attestationId, passportData)` | `CommitmentGenerator.generate()` | New helper | +| `packBytesAndPoseidon(bytes)` | `PoseidonUtils.packBytesAndHash()` | New helper | +| `formatMrz(mrz)` | `MrzFormatter.format()` | New helper | + +#### Document-Specific Input Generation + +**Register — Passport/ID Card:** +```kotlin +private fun generateRegisterInputs(document: IDDocument, secret: String, protocolData: ProtocolData): CircuitInputs { + val passportData = document as PassportData + val dscTree = protocolData.dscTree + + // Port of generateCircuitInputsRegister from common/src/utils/circuits/registerInputs.ts + val commitment = CommitmentGenerator.generate(secret, passportData.attestationId, passportData) + // ... format MRZ, hash eContent, generate Merkle proofs + // Return structured circuit inputs +} +``` + +**Register — Aadhaar:** +```kotlin +// Port of prepareAadhaarRegisterData +// Uses different input structure: QR data, public keys list +``` + +**Register — KYC:** +```kotlin +// Port of generateKycRegisterInput +// Uses serializedApplicantInfo, signature, pubkey +``` + +**DSC:** +```kotlin +private fun generateDscInputs(document: IDDocument, protocolData: ProtocolData): CircuitInputs { + val passportData = document as PassportData + val cscaTree = protocolData.cscaTree + + // Port of generateCircuitInputsDSC + // 1. Extract DSC signature from passport + // 2. Parse CSCA certificate + // 3. Get CSCA public key + // 4. Generate Merkle proof for CSCA in CSCA tree + // 5. Format signature and keys for circuit +} +``` + +**Disclose — Passport/ID Card:** +```kotlin +private fun generateDiscloseInputs( + document: IDDocument, + secret: String, + protocolData: ProtocolData, + request: ProvingRequest, +): CircuitInputs { + val passportData = document as PassportData + + // 1. Generate selector bits for disclosed attributes + val selectorDg1 = SelectorGenerator.getDg1Selector(document.documentCategory, request.disclosures) + + // 2. Load OFAC sparse merkle trees + val ofacTrees = protocolData.ofacTrees + + // 3. Load commitment tree (LeanIMT) + val commitmentTree = protocolData.commitmentTree + + // 4. Port of generateCircuitInputsVCandDisclose + // Inputs: secret, attestation_id, passportData, scope_hash, + // selector_dg1, selector_older_than, tree, majority, + // passportNoAndNationalitySMT, nameAndDobSMT, nameAndYobSMT, + // selector_ofac, excludedCountries, userIdentifierHash +} +``` + +#### Selector Bit Array Generation + +Port of `getSelectorDg1` — maps disclosure flags to MRZ byte positions: + +```kotlin +object SelectorGenerator { + // Passport MRZ (88 bytes) + private val passportPositions = mapOf( + "issuing_state" to (2..4), + "name" to (5..43), + "passport_number" to (44..52), + "nationality" to (54..56), + "date_of_birth" to (57..62), + "gender" to (64..64), + "expiry_date" to (65..70), + ) + + // ID Card MRZ (90 bytes) + private val idCardPositions = mapOf( + "issuing_state" to (2..4), + "passport_number" to (5..13), + "date_of_birth" to (30..35), + "gender" to (37..37), + "expiry_date" to (38..43), + "nationality" to (45..47), + "name" to (60..89), + ) + + fun getDg1Selector(category: DocumentCategory, disclosures: Disclosures): List { + val size = if (category == DocumentCategory.ID_CARD) 90 else 88 + val positions = if (category == DocumentCategory.ID_CARD) idCardPositions else passportPositions + val selector = MutableList(size) { "0" } + + disclosures.revealedAttributes.forEach { attr -> + positions[attr]?.let { range -> + for (i in range) selector[i] = "1" + } + } + return selector + } +} +``` + +--- + +### 4. `CircuitNameResolver.kt` — Map Document to Circuit Name + +Port of `getCircuitNameFromPassportData`: + +```kotlin +object CircuitNameResolver { + /** + * Resolve circuit name from document metadata. + * + * Examples: + * register_sha256_sha256_sha256_ecdsa_secp256r1 + * dsc_sha256_ecdsa_secp256r1 + * vc_and_disclose + * register_aadhaar + */ + fun resolve(document: IDDocument, circuitType: CircuitType): String { + return when (document.documentCategory) { + DocumentCategory.AADHAAR -> when (circuitType) { + CircuitType.REGISTER -> "register_aadhaar" + CircuitType.DISCLOSE -> "vc_and_disclose_aadhaar" + CircuitType.DSC -> throw IllegalArgumentException("Aadhaar has no DSC circuit") + } + DocumentCategory.KYC -> when (circuitType) { + CircuitType.REGISTER -> "register_kyc" + CircuitType.DISCLOSE -> "vc_and_disclose_kyc" + CircuitType.DSC -> throw IllegalArgumentException("KYC has no DSC circuit") + } + DocumentCategory.PASSPORT, DocumentCategory.ID_CARD -> { + val metadata = (document as PassportData).passportMetadata + ?: throw ProvingException("METADATA_MISSING", "Passport metadata required") + when (circuitType) { + CircuitType.REGISTER -> buildRegisterCircuitName(metadata, document.documentCategory) + CircuitType.DSC -> buildDscCircuitName(metadata) + CircuitType.DISCLOSE -> if (document.documentCategory == DocumentCategory.PASSPORT) + "vc_and_disclose" else "vc_and_disclose_id" + } + } + } + } + + /** + * Build register circuit name from passport metadata. + * Format: register_{dgHash}_{eContentHash}_{signedAttrHash}_{sigAlg}_{curveOrExp}[_{saltLen}][_{bits}] + */ + private fun buildRegisterCircuitName(metadata: PassportMetadata, category: DocumentCategory): String { + val prefix = if (category == DocumentCategory.ID_CARD) "register_id" else "register" + val parts = mutableListOf( + prefix, + metadata.dg1HashFunction, + metadata.eContentHashFunction, + metadata.signedAttrHashFunction, + metadata.signatureAlgorithm, + metadata.curveOrExponent, + ) + metadata.saltLength?.let { parts.add(it) } + metadata.signatureAlgorithmBits?.let { parts.add(it) } + return parts.joinToString("_") + } + + /** + * Build DSC circuit name from passport metadata. + * Format: dsc_{hash}_{sigAlg}_{curve} (ECDSA) + * dsc_{hash}_{sigAlg}_{exp}_{bits} (RSA) + * dsc_{hash}_{sigAlg}_{exp}_{saltLen}_{bits} (RSA-PSS) + */ + private fun buildDscCircuitName(metadata: PassportMetadata): String { + // Similar construction logic + val parts = mutableListOf("dsc", metadata.cscaHashFunction, metadata.cscaSignatureAlgorithm, metadata.cscaCurveOrExponent) + metadata.cscaSaltLength?.let { parts.add(it) } + metadata.cscaSignatureAlgorithmBits?.let { parts.add(it) } + return parts.joinToString("_") + } +} +``` + +#### Mapping Key Resolution + +Port of `getMappingKey` — maps (circuitType, documentCategory) to protocol store keys: + +```kotlin +object MappingKeyResolver { + fun resolve(circuitType: CircuitType, category: DocumentCategory): String = when (category) { + DocumentCategory.PASSPORT -> when (circuitType) { + CircuitType.REGISTER -> "REGISTER" + CircuitType.DSC -> "DSC" + CircuitType.DISCLOSE -> "DISCLOSE" + } + DocumentCategory.ID_CARD -> when (circuitType) { + CircuitType.REGISTER -> "REGISTER_ID" + CircuitType.DSC -> "DSC_ID" + CircuitType.DISCLOSE -> "DISCLOSE_ID" + } + DocumentCategory.AADHAAR -> when (circuitType) { + CircuitType.REGISTER -> "REGISTER_AADHAAR" + CircuitType.DISCLOSE -> "DISCLOSE_AADHAAR" + CircuitType.DSC -> throw IllegalArgumentException("Aadhaar has no DSC") + } + DocumentCategory.KYC -> when (circuitType) { + CircuitType.REGISTER -> "REGISTER_KYC" + CircuitType.DISCLOSE -> "DISCLOSE_KYC" + CircuitType.DSC -> throw IllegalArgumentException("KYC has no DSC") + } + } +} +``` + +--- + +### 5. `TeeConnection.kt` — WebSocket Client for TEE + +Port of WebSocket handling from `provingMachine.ts`. + +```kotlin +class TeeConnection(private val config: ProvingConfig) { + private var ws: WebSocketSession? = null + private val client = HttpClient { install(WebSockets) } + + /** + * Connect to TEE WebSocket, perform hello + attestation handshake. + * + * @return TeeSession with shared key and connection UUID + */ + suspend fun connect( + circuitName: String, + wsUrl: String, + onReconnect: ((Int) -> Unit)? = null, + ): TeeSession { + val ecdhKeyPair = PlatformCrypto.generateEcdhKeyPair() + val connectionUuid = generateUuid() + + return withRetry(maxAttempts = 3, onRetry = onReconnect) { + val session = client.webSocketSession(wsUrl) + ws = session + + // Step 1: Send hello + val helloMessage = buildJsonObject { + put("jsonrpc", "2.0") + put("method", "openpassport_hello") + put("id", 1) + putJsonObject("params") { + putJsonArray("user_pubkey") { + ecdhKeyPair.publicKey.forEach { add(it.toInt()) } + } + put("uuid", connectionUuid) + } + } + session.send(Frame.Text(Json.encodeToString(helloMessage))) + + // Step 2: Wait for attestation response + val attestationFrame = session.incoming.receive() as Frame.Text + val response = Json.parseToJsonElement(attestationFrame.readText()).jsonObject + val result = response["result"]?.jsonObject + ?: throw ProvingException("TEE_ERROR", "No result in attestation response") + + val attestationToken = result["attestation"]?.jsonPrimitive?.content + ?: throw ProvingException("TEE_ERROR", "No attestation token") + + // Step 3: Validate attestation + val attestation = TeeAttestation.validate(attestationToken, config.debug) + + // Step 4: Verify client pubkey matches + if (!ecdhKeyPair.publicKey.contentEquals(attestation.userPubkey)) { + throw ProvingException("TEE_ERROR", "User public key mismatch in attestation") + } + + // Step 5: Check PCR0 mapping + TeeAttestation.checkPcr0(attestation.imageHash, config.environment) + + // Step 6: Derive shared key via ECDH + val sharedKey = PlatformCrypto.deriveSharedSecret( + ecdhKeyPair.privateKey, + attestation.serverPubkey, + ) + + TeeSession( + ws = session, + sharedKey = sharedKey, + connectionUuid = connectionUuid, + ) + } + } + + /** + * Submit encrypted proof request to TEE. + * + * @return UUID for status polling + */ + suspend fun submitProof(session: TeeSession, encrypted: EncryptedPayload): String { + val submitMessage = buildJsonObject { + put("jsonrpc", "2.0") + put("method", "openpassport_submit_request") + put("id", 2) + putJsonObject("params") { + put("uuid", session.connectionUuid) + putJsonArray("nonce") { encrypted.nonce.forEach { add(it.toInt()) } } + putJsonArray("cipher_text") { encrypted.ciphertext.forEach { add(it.toInt()) } } + putJsonArray("auth_tag") { encrypted.authTag.forEach { add(it.toInt()) } } + } + } + session.ws.send(Frame.Text(Json.encodeToString(submitMessage))) + + // Wait for ACK with status UUID + val ackFrame = session.ws.incoming.receive() as Frame.Text + val ackResponse = Json.parseToJsonElement(ackFrame.readText()).jsonObject + val statusUuid = ackResponse["result"]?.jsonPrimitive?.content + ?: throw ProvingException("TEE_ERROR", "No UUID in submit ACK") + + return statusUuid + } + + fun close() { + // Close WebSocket + } +} + +data class TeeSession( + val ws: WebSocketSession, + val sharedKey: ByteArray, + val connectionUuid: String, +) +``` + +#### Retry Logic + +```kotlin +private suspend fun withRetry( + maxAttempts: Int, + onRetry: ((Int) -> Unit)?, + block: suspend () -> T, +): T { + var lastException: Exception? = null + for (attempt in 1..maxAttempts) { + try { + return block() + } catch (e: Exception) { + lastException = e + if (attempt < maxAttempts) { + val backoffMs = minOf(1000L * (1 shl (attempt - 1)), 10_000L) + delay(backoffMs) + onRetry?.invoke(attempt + 1) + } + } + } + throw lastException ?: ProvingException("TEE_CONNECT_FAILED", "Max reconnect attempts reached") +} +``` + +--- + +### 6. `TeeAttestation.kt` — Attestation Validation + +Port of `validatePKIToken` and `checkPCR0Mapping` from `common/src/utils/attest.ts`. + +```kotlin +object TeeAttestation { + /** + * Validate TEE attestation JWT token. + * + * JWT structure: header.payload.signature (RS256) + * Header x5c: [leaf, intermediate, root] certificate chain + * Payload eat_nonce: [userPubkey, serverPubkey] (base64) + * Payload submods.container.image_digest: "sha256:" + * + * @param attestationToken JWT string + * @param isDev If true, skip debug status check + * @return Validated attestation data + */ + fun validate(attestationToken: String, isDev: Boolean): AttestationResult { + val (headerB64, payloadB64, signatureB64) = attestationToken.split(".") + + // 1. Parse header, extract x5c certificate chain + val header = Json.parseToJsonElement(base64UrlDecode(headerB64).decodeToString()).jsonObject + val x5c = header["x5c"]?.jsonArray?.map { it.jsonPrimitive.content } + ?: throw ProvingException("ATTESTATION_ERROR", "No x5c in header") + require(x5c.size == 3) { "Expected 3 certificates in x5c chain" } + + // 2. Parse certificates: leaf, intermediate, root + val certs = x5c.map { PlatformCrypto.parseX509Certificate(pemFromBase64(it)) } + + // 3. Verify root matches stored GCP root certificate + verifyRootCertificate(certs[2]) + + // 4. Verify certificate chain signatures + verifyCertificateChain(certs) + + // 5. Verify JWT signature (RS256) using leaf certificate + val signingInput = "$headerB64.$payloadB64".encodeToByteArray() + val signature = base64UrlDecode(signatureB64) + require(PlatformCrypto.verifyRsaSha256(certs[0].publicKeyBytes, signingInput, signature)) { + "JWT signature verification failed" + } + + // 6. Parse payload + val payload = Json.parseToJsonElement(base64UrlDecode(payloadB64).decodeToString()).jsonObject + + // 7. Check debug status (skip in dev mode) + if (!isDev) { + val dbgstat = payload["dbgstat"]?.jsonPrimitive?.content + require(dbgstat == "disabled-since-boot") { "Debug mode is enabled on TEE" } + } + + // 8. Extract keys and image hash + val eatNonce = payload["eat_nonce"]?.jsonArray + ?: throw ProvingException("ATTESTATION_ERROR", "No eat_nonce in payload") + val userPubkey = base64Decode(eatNonce[0].jsonPrimitive.content) + val serverPubkey = base64Decode(eatNonce[1].jsonPrimitive.content) + val imageDigest = payload["submods"]?.jsonObject + ?.get("container")?.jsonObject + ?.get("image_digest")?.jsonPrimitive?.content + ?: throw ProvingException("ATTESTATION_ERROR", "No image_digest") + val imageHash = imageDigest.removePrefix("sha256:") + + return AttestationResult( + userPubkey = userPubkey, + serverPubkey = serverPubkey, + imageHash = imageHash, + verified = true, + ) + } + + /** + * Check PCR0 hash against on-chain PCR0Manager contract. + */ + suspend fun checkPcr0(imageHashHex: String, environment: Environment) { + require(imageHashHex.length == 64) { "Invalid PCR0 hash length: ${imageHashHex.length}" } + + // Query PCR0Manager contract on Celo via JSON-RPC + val rpcUrl = "https://forno.celo.org" // Celo mainnet RPC + val pcr0ManagerAddress = PCR0_MANAGER_ADDRESS + val paddedHash = imageHashHex.padStart(96, '0') + + // Build eth_call to isPCR0Set(bytes) + val client = HttpClient() + val response = client.post(rpcUrl) { + contentType(ContentType.Application.Json) + setBody(buildJsonObject { + put("jsonrpc", "2.0") + put("method", "eth_call") + put("id", 1) + putJsonObject("params") { + // ... ABI-encoded call to isPCR0Set + } + }) + } + // Parse response, verify returns true + } + + private const val PCR0_MANAGER_ADDRESS = "0x..." // TODO: Extract from TypeScript constants + + /** + * Stored GCP Confidential Computing root certificate (PEM). + * Used to verify the TEE attestation certificate chain. + */ + private val GCP_ROOT_CERT = """ + -----BEGIN CERTIFICATE----- + ... + -----END CERTIFICATE----- + """.trimIndent() + // TODO: Extract from common/src/utils/attest.ts +} + +data class AttestationResult( + val userPubkey: ByteArray, + val serverPubkey: ByteArray, + val imageHash: String, + val verified: Boolean, +) +``` + +--- + +### 7. `PayloadEncryption.kt` — ECDH + AES-256-GCM + +Port of encryption logic from `common/src/utils/proving.ts`. + +```kotlin +object PayloadEncryption { + /** + * Encrypt payload using AES-256-GCM with the ECDH-derived shared key. + * + * @param payload JSON string to encrypt + * @param sharedKey 32-byte ECDH-derived shared key + * @return Encrypted payload with nonce, ciphertext, and auth tag + */ + fun encrypt(payload: String, sharedKey: ByteArray): EncryptedPayload { + require(sharedKey.size == 32) { "Shared key must be 32 bytes" } + + val iv = PlatformCrypto.randomBytes(12) // 12-byte random nonce + val result = PlatformCrypto.encryptAesGcm( + plaintext = payload.encodeToByteArray(), + key = sharedKey, + iv = iv, + ) + + return EncryptedPayload( + nonce = iv, + ciphertext = result.ciphertext, + authTag = result.authTag, + ) + } +} +``` + +--- + +### 8. `ProtocolDataStore.kt` — Fetch/Cache Protocol Data + +Port of protocol store fetching from `packages/mobile-sdk-alpha/src/stores/protocolStore.ts`. + +```kotlin +class ProtocolDataStore(private val config: ProvingConfig) { + private val client = HttpClient { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true }) + } + } + private val cache = mutableMapOf() + + /** + * Fetch all protocol data needed for proving. + * Fetches in parallel: DSC tree, CSCA tree, commitment tree, OFAC trees, + * deployed circuits, circuits DNS mapping, alternative CSCA. + * + * Results are cached by (environment, documentCategory) key. + */ + suspend fun fetchAll( + environment: Environment, + documentCategory: DocumentCategory, + dscSki: String? = null, + ): ProtocolData { + val cacheKey = "${environment.name}:${documentCategory.name}" + cache[cacheKey]?.let { return it } + + val baseUrl = environment.apiBaseUrl + + // Parallel fetch all data + return coroutineScope { + val deployedCircuits = async { fetchDeployedCircuits(baseUrl, documentCategory) } + val circuitsDnsMapping = async { fetchCircuitsDnsMapping(baseUrl) } + val dscTree = async { fetchDscTree(baseUrl, documentCategory) } + val cscaTree = async { fetchCscaTree(baseUrl, documentCategory) } + val commitmentTree = async { fetchCommitmentTree(baseUrl, documentCategory) } + val ofacTrees = async { fetchOfacTrees(baseUrl, documentCategory) } + val alternativeCsca = async { + if (dscSki != null) fetchAlternativeCsca(baseUrl, documentCategory, dscSki) + else null + } + + ProtocolData( + deployedCircuits = deployedCircuits.await(), + circuitsDnsMapping = circuitsDnsMapping.await(), + dscTree = dscTree.await(), + cscaTree = cscaTree.await(), + commitmentTree = commitmentTree.await(), + ofacTrees = ofacTrees.await(), + alternativeCsca = alternativeCsca.await(), + ).also { cache[cacheKey] = it } + } + } + + // --- Individual fetchers --- + + private suspend fun fetchDeployedCircuits(baseUrl: String, category: DocumentCategory): DeployedCircuits { + // GET {baseUrl}/deployed-circuits + val response = client.get("$baseUrl/deployed-circuits") + return response.body() + } + + private suspend fun fetchCircuitsDnsMapping(baseUrl: String): CircuitsDnsMapping { + // GET {baseUrl}/circuit-dns-mapping-gcp + val response = client.get("$baseUrl/circuit-dns-mapping-gcp") + return response.body() + } + + private suspend fun fetchDscTree(baseUrl: String, category: DocumentCategory): String { + // GET {baseUrl}/dsc-tree — returns serialized LeanIMT + val response = client.get("$baseUrl/dsc-tree") { + parameter("category", category.apiValue) + } + return response.body() + } + + private suspend fun fetchCscaTree(baseUrl: String, category: DocumentCategory): List> { + // GET {baseUrl}/csca-tree — returns 2D array for Merkle tree + val response = client.get("$baseUrl/csca-tree") { + parameter("category", category.apiValue) + } + return response.body() + } + + private suspend fun fetchCommitmentTree(baseUrl: String, category: DocumentCategory): String { + // GET {baseUrl}/identity-tree — returns serialized LeanIMT + val response = client.get("$baseUrl/identity-tree") { + parameter("category", category.apiValue) + } + return response.body() + } + + private suspend fun fetchOfacTrees(baseUrl: String, category: DocumentCategory): OfacTrees { + // GET {baseUrl}/ofac-trees + val response = client.get("$baseUrl/ofac-trees") { + parameter("category", category.apiValue) + } + return response.body() + } + + private suspend fun fetchAlternativeCsca(baseUrl: String, category: DocumentCategory, ski: String): AlternativeCsca { + // GET {baseUrl}/alternative-csca + val response = client.get("$baseUrl/alternative-csca") { + parameter("category", category.apiValue) + parameter("ski", ski) + } + return response.body() + } +} +``` + +#### TypeScript Fetch Functions → Kotlin Equivalents + +| TypeScript | Kotlin | API Endpoint | +|---|---|---| +| `fetch_deployed_circuits(env)` | `fetchDeployedCircuits()` | `GET /deployed-circuits` | +| `fetch_circuits_dns_mapping(env)` | `fetchCircuitsDnsMapping()` | `GET /circuit-dns-mapping-gcp` | +| `fetch_dsc_tree(env)` | `fetchDscTree()` | `GET /dsc-tree` | +| `fetch_csca_tree(env)` | `fetchCscaTree()` | `GET /csca-tree` | +| `fetch_identity_tree(env)` | `fetchCommitmentTree()` | `GET /identity-tree` | +| `fetch_ofac_trees(env)` | `fetchOfacTrees()` | `GET /ofac-trees` | +| `fetch_alternative_csca(env, ski)` | `fetchAlternativeCsca()` | `GET /alternative-csca` | + +--- + +### 9. `DocumentValidator.kt` — Document Validation + +Port of validation logic from `common/src/utils/passports/validate.ts`. + +```kotlin +object DocumentValidator { + /** + * Validate document eligibility for the given circuit type. + * + * Checks performed: + * - Document metadata exists and CSCA was found during parsing + * - Register + DSC circuits are deployed for this document's crypto params + * - For disclose: user is registered (commitment exists in tree) + * - For register: user is NOT already registered, document NOT nullified + * - For register: DSC is in the DSC tree + */ + suspend fun validate( + document: IDDocument, + secret: String, + circuitType: CircuitType, + protocolData: ProtocolData, + environment: Environment, + ): ValidationResult { + // Step 1: Check document supported (metadata, CSCA, circuits deployed) + val supportStatus = checkDocumentSupported(document, protocolData.deployedCircuits) + if (supportStatus != SupportStatus.SUPPORTED) { + return ValidationResult.NotSupported(supportStatus.name) + } + + // Step 2: Circuit-type-specific validation + return when (circuitType) { + CircuitType.DISCLOSE -> validateForDisclose(document, secret, protocolData) + CircuitType.REGISTER -> validateForRegister(document, secret, protocolData, environment) + CircuitType.DSC -> ValidationResult.Supported // DSC has no extra validation + } + } +} +``` + +#### TypeScript Functions → Kotlin Equivalents + +| TypeScript Function | Kotlin Equivalent | Purpose | +|---|---|---| +| `checkDocumentSupported(passportData, opts)` | `DocumentValidator.checkDocumentSupported()` | Check metadata, CSCA, deployed circuits | +| `isUserRegistered(documentData, secret, getCommitmentTree)` | `DocumentValidator.isUserRegistered()` | Look up commitment in LeanIMT | +| `isUserRegisteredWithAlternativeCSCA(passportData, secret, opts)` | `DocumentValidator.isUserRegisteredWithAlternativeCsca()` | Try each alternative CSCA commitment | +| `isDocumentNullified(passportData)` | `DocumentValidator.isDocumentNullified()` | POST to `/is-nullifier-onchain-with-attestation-id` | +| `checkIfPassportDscIsInTree(passportData, dscTree)` | `DocumentValidator.isDscInTree()` | Look up DSC leaf in LeanIMT | + +#### Commitment Generation + +Port of `generateCommitment` from `common/src/utils/passports/passport.ts`: + +```kotlin +object CommitmentGenerator { + /** + * Generate a commitment hash for a passport/ID document. + * commitment = poseidon5(secret, attestation_id, dg1_packed_hash, eContent_packed_hash, dsc_hash) + * + * @param secret User's secret key + * @param attestationId "1" for passport, "2" for ID card + * @param passportData Parsed passport data with MRZ, eContent, parsed certificates + * @return Commitment as string representation of BigInt + */ + fun generate(secret: String, attestationId: String, passportData: PassportData): String { + // 1. Pack MRZ bytes and hash with Poseidon + val mrzBytes = MrzFormatter.format(passportData.mrz) + val dg1PackedHash = PoseidonUtils.packBytesAndHash(mrzBytes) + + // 2. Hash eContent, then pack and hash with Poseidon + val eContentShaBytes = PlatformCrypto.sha256(passportData.eContent) // Or sha384/sha512 based on metadata + val eContentPackedHash = PoseidonUtils.packBytesAndHash(eContentShaBytes.map { it.toInt() and 0xff }) + + // 3. Get DSC tree leaf hash + val dscHash = getLeafDscTree(passportData.dscParsed, passportData.cscaParsed) + + // 4. Poseidon-5 hash + return poseidon5(listOf( + secret.toBigInteger(), + attestationId.toBigInteger(), + dg1PackedHash, + eContentPackedHash, + dscHash, + )).toString() + } +} +``` + +#### Constants + +```kotlin +object AttestationId { + const val PASSPORT = "1" + const val ID_CARD = "2" + const val AADHAAR = "3" + const val KYC = "4" +} +``` + +--- + +### 10. `StatusListener.kt` — Socket.IO Status Polling + +Port of `_startSocketIOStatusListener` from `provingMachine.ts`. + +**Note**: Ktor doesn't have native Socket.IO support. Use Ktor WebSocket with manual Socket.IO protocol handling, or use a KMP Socket.IO client library. + +```kotlin +object StatusListener { + /** + * Connect to Socket.IO relayer and wait for proof status. + * + * Status codes: + * 3, 5 = Failure + * 4 = Success + * Other = In progress (keep listening) + * + * @param uuid Proof UUID returned by TEE submit + * @param environment Determines which relayer URL to use + * @return Final status (success or failure) + */ + suspend fun awaitResult( + uuid: String, + environment: Environment, + timeout: Duration = 5.minutes, + ): ProofStatus { + val wsUrl = environment.wsRelayerUrl + + return withTimeout(timeout) { + // Connect via WebSocket (Socket.IO over WS) + val client = HttpClient { install(WebSockets) } + val session = client.webSocketSession("$wsUrl/socket.io/?transport=websocket") + + try { + // Socket.IO handshake + // ... send "40" (connect to default namespace) + // ... wait for "40" ack + + // Subscribe to UUID + val subscribeMsg = """42["subscribe","$uuid"]""" + session.send(Frame.Text(subscribeMsg)) + + // Listen for status messages + for (frame in session.incoming) { + if (frame is Frame.Text) { + val text = frame.readText() + val status = parseSocketIoStatus(text) ?: continue + + when (status.code) { + 3, 5 -> return@withTimeout ProofStatus( + isSuccess = false, + errorCode = status.errorCode, + reason = status.reason, + ) + 4 -> return@withTimeout ProofStatus(isSuccess = true) + // Other status codes = in progress, keep listening + } + } + } + throw ProvingException("TIMEOUT", "Socket.IO connection closed without final status") + } finally { + session.close() + } + } + } + + private fun parseSocketIoStatus(message: String): StatusMessage? { + // Socket.IO message format: "42[\"status\",{...}]" + if (!message.startsWith("42")) return null + val jsonPart = message.substring(2) + val array = Json.parseToJsonElement(jsonPart).jsonArray + if (array[0].jsonPrimitive.content != "status") return null + val data = array[1].jsonObject + return StatusMessage( + code = data["status"]?.jsonPrimitive?.int ?: return null, + errorCode = data["error_code"]?.jsonPrimitive?.content, + reason = data["reason"]?.jsonPrimitive?.content, + ) + } +} + +data class ProofStatus( + val isSuccess: Boolean, + val errorCode: String? = null, + val reason: String? = null, +) + +data class StatusMessage( + val code: Int, + val errorCode: String?, + val reason: String?, +) +``` + +--- + +## Data Models + +### `ProofResult.kt` + +```kotlin +data class ProofResult( + val success: Boolean, + val circuitType: CircuitType, + val uuid: String? = null, + val claims: Map? = null, +) +``` + +### `CircuitType.kt` + +```kotlin +enum class CircuitType { + DSC, REGISTER, DISCLOSE +} +``` + +### `DocumentCategory.kt` + +```kotlin +enum class DocumentCategory(val apiValue: String) { + PASSPORT("passport"), + ID_CARD("id_card"), + AADHAAR("aadhaar"), + KYC("kyc"); +} +``` + +### `ProvingRequest.kt` + +```kotlin +data class ProvingRequest( + val circuitType: CircuitType, + val disclosures: Disclosures = Disclosures(), + val scope: String? = null, + val endpoint: String? = null, + val endpointType: EndpointType = EndpointType.CELO, + val chainId: Int? = null, + val userId: String? = null, + val userDefinedData: String = "", + val selfDefinedData: String = "", + val version: Int = 1, +) + +data class Disclosures( + val name: Boolean = false, + val dateOfBirth: Boolean = false, + val gender: Boolean = false, + val passportNumber: Boolean = false, + val issuingState: Boolean = false, + val nationality: Boolean = false, + val expiryDate: Boolean = false, + val ofac: Boolean = false, + val excludedCountries: List = emptyList(), + val minimumAge: Int? = null, +) { + val revealedAttributes: List get() = buildList { + if (name) add("name") + if (dateOfBirth) add("date_of_birth") + if (gender) add("gender") + if (passportNumber) add("passport_number") + if (issuingState) add("issuing_state") + if (nationality) add("nationality") + if (expiryDate) add("expiry_date") + } +} +``` + +### `EndpointType.kt` + +```kotlin +enum class EndpointType { + CELO, STAGING_CELO, HTTPS +} +``` + +### `IDDocument.kt` (common model hierarchy) + +```kotlin +sealed class IDDocument { + abstract val documentCategory: DocumentCategory + abstract val mock: Boolean + abstract val dscAuthorityKeyIdentifier: String? +} + +data class PassportData( + override val documentCategory: DocumentCategory, // PASSPORT or ID_CARD + override val mock: Boolean, + override val dscAuthorityKeyIdentifier: String?, + val mrz: String, // 88-char MRZ string + val eContent: ByteArray, + val dsc: String, // DSC certificate PEM + val passportMetadata: PassportMetadata?, + val dscParsed: ParsedCertificate?, + val cscaParsed: ParsedCertificate?, +) : IDDocument() + +data class PassportMetadata( + val countryCode: String, + val cscaFound: Boolean, + val dg1HashFunction: String, // "sha256", "sha384", "sha512" + val eContentHashFunction: String, + val signedAttrHashFunction: String, + val signatureAlgorithm: String, // "ecdsa", "rsa", "rsapss" + val curveOrExponent: String, // "secp256r1", "65537", etc. + val saltLength: String? = null, + val signatureAlgorithmBits: String? = null, + val cscaHashFunction: String? = null, + val cscaSignatureAlgorithm: String? = null, + val cscaCurveOrExponent: String? = null, + val cscaSaltLength: String? = null, + val cscaSignatureAlgorithmBits: String? = null, +) + +data class AadhaarData( + override val documentCategory: DocumentCategory = DocumentCategory.AADHAAR, + override val mock: Boolean, + override val dscAuthorityKeyIdentifier: String? = null, + val qrData: String, + val extractedFields: AadhaarFields, + val publicKey: String, +) : IDDocument() + +data class KycData( + override val documentCategory: DocumentCategory = DocumentCategory.KYC, + override val mock: Boolean, + override val dscAuthorityKeyIdentifier: String? = null, + val serializedApplicantInfo: String, + val signature: String, + val pubkey: Pair, +) : IDDocument() +``` + +### `ProtocolData.kt` + +```kotlin +data class ProtocolData( + val deployedCircuits: DeployedCircuits, + val circuitsDnsMapping: CircuitsDnsMapping, + val dscTree: String, // Serialized LeanIMT + val cscaTree: List>, // 2D Merkle tree + val commitmentTree: String, // Serialized LeanIMT + val ofacTrees: OfacTrees, + val alternativeCsca: AlternativeCsca?, +) { + /** + * Resolve WebSocket URL for a specific circuit from DNS mapping. + */ + fun resolveWebSocketUrl(circuitType: CircuitType, document: IDDocument): String { + val mappingKey = MappingKeyResolver.resolve(circuitType, document.documentCategory) + val circuitName = CircuitNameResolver.resolve(document, circuitType) + return circuitsDnsMapping.mapping[mappingKey]?.get(circuitName) + ?: throw ProvingException("CIRCUIT_NOT_FOUND", + "No WebSocket URL for $mappingKey/$circuitName") + } +} + +@Serializable +data class DeployedCircuits( + @SerialName("REGISTER") val register: List = emptyList(), + @SerialName("REGISTER_ID") val registerId: List = emptyList(), + @SerialName("REGISTER_AADHAAR") val registerAadhaar: List = emptyList(), + @SerialName("REGISTER_KYC") val registerKyc: List = emptyList(), + @SerialName("DSC") val dsc: List = emptyList(), + @SerialName("DSC_ID") val dscId: List = emptyList(), + @SerialName("DISCLOSE") val disclose: List = emptyList(), + @SerialName("DISCLOSE_ID") val discloseId: List = emptyList(), + @SerialName("DISCLOSE_AADHAAR") val discloseAadhaar: List = emptyList(), + @SerialName("DISCLOSE_KYC") val discloseKyc: List = emptyList(), +) { + fun getCircuits(mappingKey: String): List = when (mappingKey) { + "REGISTER" -> register + "REGISTER_ID" -> registerId + "REGISTER_AADHAAR" -> registerAadhaar + "REGISTER_KYC" -> registerKyc + "DSC" -> dsc + "DSC_ID" -> dscId + "DISCLOSE" -> disclose + "DISCLOSE_ID" -> discloseId + "DISCLOSE_AADHAAR" -> discloseAadhaar + "DISCLOSE_KYC" -> discloseKyc + else -> emptyList() + } +} + +typealias CircuitsDnsMapping = Map> + +@Serializable +data class OfacTrees( + val nameAndDob: String, // Serialized SMT + val nameAndYob: String, // Serialized SMT + val passportNoAndNationality: String? = null, // Passport only +) + +typealias AlternativeCsca = Map // CSCA label → PEM +``` + +### `PayloadModels.kt` + +```kotlin +data class EncryptedPayload( + val nonce: ByteArray, // 12 bytes + val ciphertext: ByteArray, + val authTag: ByteArray, // 16 bytes +) + +data class CircuitInputs( + val inputs: JsonElement, + val circuitName: String, + val endpointType: EndpointType, + val endpoint: String, +) +``` + +### `PayloadBuilder.kt` + +Port of `getPayload` from `common/src/utils/proving.ts`: + +```kotlin +object PayloadBuilder { + /** + * Build the plaintext payload to encrypt and send to TEE. + * Handles BigInt serialization via custom replacer. + */ + fun build( + inputs: CircuitInputs, + circuitType: CircuitType, + document: IDDocument, + request: ProvingRequest, + ): String { + val payloadJson = buildJsonObject { + put("type", resolvePayloadType(circuitType, document.documentCategory)) + put("onchain", inputs.endpointType == EndpointType.CELO) + put("endpointType", inputs.endpointType.name.lowercase()) + putJsonObject("circuit") { + put("name", inputs.circuitName) + put("inputs", Json.encodeToString(inputs.inputs)) // Nested JSON string + } + + // Disclose-specific fields + if (circuitType == CircuitType.DISCLOSE) { + put("endpoint", inputs.endpoint) + put("version", request.version) + put("userDefinedData", request.userDefinedData) + put("selfDefinedData", request.selfDefinedData) + } + } + return Json.encodeToString(payloadJson) + } + + private fun resolvePayloadType(circuitType: CircuitType, category: DocumentCategory): String { + return when (circuitType) { + CircuitType.REGISTER -> when (category) { + DocumentCategory.PASSPORT -> "register" + DocumentCategory.ID_CARD -> "register_id" + DocumentCategory.AADHAAR -> "register_aadhaar" + DocumentCategory.KYC -> "register_kyc" + } + CircuitType.DSC -> when (category) { + DocumentCategory.PASSPORT -> "dsc" + DocumentCategory.ID_CARD -> "dsc_id" + else -> throw IllegalArgumentException("DSC not supported for $category") + } + CircuitType.DISCLOSE -> when (category) { + DocumentCategory.PASSPORT -> "disclose" + DocumentCategory.ID_CARD -> "disclose_id" + DocumentCategory.AADHAAR -> "disclose_aadhaar" + DocumentCategory.KYC -> "disclose_kyc" + } + } + } +} +``` + +--- + +## Chunking Guide + +### Chunk 4A: Models + ProtocolDataStore (start here) + +**Goal**: Define all data models and implement HTTP fetching. + +**Steps**: +1. Create all model files in `proving/models/` +2. Implement `ProtocolDataStore.kt` with all 7 fetchers +3. Add Ktor HTTP client dependencies to `build.gradle.kts` +4. Write unit tests for model serialization +5. Validate: API calls return correct data, models deserialize + +### Chunk 4B: Platform Crypto + +**Goal**: Implement `expect`/`actual` for all cryptographic operations. + +**Steps**: +1. Define `PlatformCrypto` expect in `commonMain` +2. Implement `androidMain` actual (BouncyCastle + JCE) +3. Implement `iosMain` actual (Security framework via Swift provider, or direct CommonCrypto cinterop) +4. Test: ECDH key exchange, AES-GCM encrypt/decrypt roundtrip, SHA-256, RSA verify +5. Validate: Same inputs produce same outputs on both platforms + +**Note**: For iOS, crypto can use the same Swift provider pattern from [SPEC-IOS-HANDLERS.md](./SPEC-IOS-HANDLERS.md) — add a `PlatformCryptoProvider` interface and Swift implementation. Or if CommonCrypto cinterop works (it's simpler than UIKit cinterop), use it directly. + +### Chunk 4C: TEE Connection + Attestation + +**Goal**: WebSocket connection, attestation validation, ECDH handshake. + +**Steps**: +1. Implement `TeeConnection.kt` — WebSocket connect, hello, receive attestation +2. Implement `TeeAttestation.kt` — JWT validation, certificate chain, PCR0 check +3. Implement `PayloadEncryption.kt` — AES-GCM encrypt with shared key +4. Add Ktor WebSocket dependencies +5. Test: Connect to TEE endpoint, validate attestation, derive shared key + +### Chunk 4D: Document Validation + Circuit Names + +**Goal**: All validation logic and circuit name resolution. + +**Steps**: +1. Implement `DocumentValidator.kt` with all validation functions +2. Implement `CircuitNameResolver.kt` and `MappingKeyResolver` +3. Implement `CommitmentGenerator.kt` (requires Poseidon hash — see dependencies below) +4. Implement `SelectorGenerator.kt` for DG1 selector bits +5. Port LeanIMT tree operations (import, indexOf) for commitment/DSC lookup +6. Test: Validation returns correct results for each document type + +**Poseidon Hash Dependency**: The TypeScript uses `poseidon-lite` for Poseidon-2 and Poseidon-5. Options for Kotlin: +- Port the Poseidon implementation to Kotlin (the algorithm is well-defined, ~200 lines) +- Use a KMP-compatible Poseidon library if one exists +- Call into JavaScript via embedded engine (last resort) + +### Chunk 4E: Circuit Input Generation + +**Goal**: Port all circuit input generators. + +**Steps**: +1. Implement `CircuitInputGenerator.kt` — register, DSC, disclose for each document type +2. Port `generateCircuitInputsRegister` from `common/src/utils/circuits/registerInputs.ts` +3. Port `generateCircuitInputsDSC` +4. Port `generateCircuitInputsVCandDisclose` +5. Port Aadhaar and KYC-specific input generators +6. Test: Same inputs as TypeScript for known test vectors + +### Chunk 4F: State Machine + Status Listener + Public API + +**Goal**: Wire everything together. + +**Steps**: +1. Implement `ProvingStateMachine.kt` — orchestrate the full flow +2. Implement `StatusListener.kt` — Socket.IO polling +3. Implement `ProvingClient.kt` — public API +4. Implement `PayloadBuilder.kt` — payload construction +5. Integration test: Full prove() flow against staging TEE +6. Validate: End-to-end proof generation works on both platforms + +--- + +## Critical Implementation Notes + +### BigInt Handling + +TypeScript uses native `BigInt` extensively for circuit inputs and Poseidon hashes. Kotlin options: +- `java.math.BigInteger` on JVM/Android +- Need a `commonMain` BigInt: Use `com.ionspin.kotlin.bignum:bignum` KMP library, or define expect/actual wrapping platform BigInteger + +### Poseidon Hash + +Must produce identical outputs to `poseidon-lite` npm package. The Poseidon hash uses specific round constants and parameters for each width (2, 5). Port the constants from the npm package source. + +### LeanIMT (Lean Incremental Merkle Tree) + +Port of `@openpassport/zk-kit-lean-imt`. Key operations needed: +- `import(hashFn, serialized)` — deserialize tree from JSON +- `indexOf(leaf)` — find leaf index in tree +- `generateProof(index)` — generate inclusion proof (for circuit inputs) + +### SMT (Sparse Merkle Tree) + +Port of `@openpassport/zk-kit-smt`. Used for OFAC checks. Key operations: +- `import(serialized)` — deserialize +- `createProof(key)` — inclusion/exclusion proof + +### JSON Serialization with BigInt + +Circuit inputs contain BigInt values that must be serialized as strings (not numbers) in JSON. Use a custom serializer: + +```kotlin +fun bigIntReplacer(value: Any): Any = when (value) { + is BigInteger -> value.toString() + else -> value +} +``` + +--- + +## Testing Strategy + +### Unit Tests (`commonTest/`) + +**Models & Serialization** (~10 tests): +- All `@Serializable` models roundtrip through JSON +- `DeployedCircuits` deserializes from real API response snapshot +- `CircuitsDnsMapping` deserializes correctly +- `ProvingRequest` defaults are correct +- `Disclosures.revealedAttributes` computes correctly + +**Circuit Name Resolution** (~15 tests): +- `CircuitNameResolver.resolve()` for each (documentCategory × circuitType) combination +- Known passport metadata → expected circuit name string (e.g., `register_sha256_sha256_sha256_ecdsa_secp256r1`) +- Known DSC metadata → expected DSC circuit name +- Aadhaar/KYC → correct fixed names +- Error: DSC for Aadhaar throws +- `MappingKeyResolver.resolve()` for all 10 mapping key combinations + +**Payload Builder** (~8 tests): +- Register payload has `onchain: true`, correct `type` field +- Disclose payload includes `endpoint`, `version`, `userDefinedData`, `selfDefinedData` +- Payload type strings correct for each (circuitType × documentCategory) +- BigInt values serialized as strings (not numbers) + +**Document Validation** (~12 tests): +- `checkDocumentSupported` returns correct status for: supported passport, missing metadata, missing CSCA, undeployed register circuit, undeployed DSC circuit +- `isUserRegistered` returns true when commitment is in tree, false when not +- `isDscInTree` returns true when DSC leaf is in tree, false when not +- Aadhaar/KYC validation paths + +**TEE Attestation** (~8 tests): +- `validate()` with known good attestation token → extracts correct pubkeys and image hash +- `validate()` with tampered signature → throws +- `validate()` with wrong root cert → throws +- `validate()` with debug mode enabled + isDev=false → throws +- `validate()` with debug mode enabled + isDev=true → succeeds + +**Payload Encryption** (~5 tests): +- Encrypt with known key → decrypt with same key → matches plaintext +- Nonce is 12 bytes, auth tag is 16 bytes +- Different encryptions of same plaintext produce different ciphertexts (random IV) + +### Integration Tests (staging environment) + +**Protocol Data Fetching**: +- `fetchAll()` against staging API returns non-empty data for passport category +- Deployed circuits list is non-empty +- Circuits DNS mapping contains expected keys +- Commitment tree deserializes and has positive size +- Caching works: second `fetchAll()` returns same instance + +**TEE Connection**: +- Connect to staging TEE WebSocket → receive attestation → validate → derive shared key +- Submit encrypted payload → receive UUID ACK +- Reconnection: close socket, verify reconnect succeeds within 3 attempts + +**Socket.IO Status Listener**: +- Connect to staging relayer → subscribe to UUID → receive status messages +- Timeout fires if no status received within limit + +**End-to-End** (requires mock passport): +- `ProvingClient.prove()` with mock passport data → register circuit → success +- `ProvingClient.prove()` with mock passport data → disclose circuit → success +- State change callbacks fire in correct order +- Error propagation: invalid document → `ProvingException` with correct code + +### Cross-Platform Verification + +- Run `commonTest` on JVM and iOS: `./gradlew :shared:jvmTest :shared:iosSimulatorArm64Test` +- Platform crypto (`PlatformCrypto`): Same ECDH shared secret from same key pairs on Android and iOS +- Same AES-GCM encryption with fixed IV produces identical ciphertext on both platforms + - Same inputs → same commitment hash on Android and iOS + +--- + +## Dependencies + +- **SPEC-KMP-SDK.md**: Bridge protocol, common models (complete) +- **SPEC-IOS-HANDLERS.md**: iOS crypto provider (Chunk 4B may need this for iOS actual) +- **SPEC-MINIPAY-SAMPLE.md**: Depends on this spec's `ProvingClient` public API + +## Key TypeScript Reference Files + +| TypeScript File | What to Port | Kotlin Target | +|---|---|---| +| `packages/mobile-sdk-alpha/src/proving/provingMachine.ts` | State machine, WebSocket handling, proof flow | `ProvingStateMachine.kt`, `TeeConnection.kt` | +| `packages/mobile-sdk-alpha/src/proving/internal/statusHandlers.ts` | Socket.IO status parsing | `StatusListener.kt` | +| `common/src/utils/proving.ts` | `getPayload`, `encryptAES256GCM`, `getWSDbRelayerUrl` | `PayloadBuilder.kt`, `PayloadEncryption.kt` | +| `common/src/utils/attest.ts` | `validatePKIToken`, `checkPCR0Mapping` | `TeeAttestation.kt` | +| `common/src/utils/circuits/registerInputs.ts` | `generateCircuitInputsRegister`, `generateCircuitInputsDSC`, `generateCircuitInputsVCandDisclose`, `getSelectorDg1` | `CircuitInputGenerator.kt`, `SelectorGenerator.kt` | +| `common/src/utils/circuits/circuitsName.ts` | `getCircuitNameFromPassportData` | `CircuitNameResolver.kt` | +| `common/src/utils/passports/validate.ts` | `checkDocumentSupported`, `isUserRegistered`, `isDocumentNullified`, `checkIfPassportDscIsInTree` | `DocumentValidator.kt` | +| `common/src/utils/passports/passport.ts` | `generateCommitment`, `packBytesAndPoseidon`, `formatMrz` | `CommitmentGenerator.kt`, `PoseidonUtils.kt` | +| `common/src/constants/constants.ts` | `PASSPORT_ATTESTATION_ID`, `ID_CARD_ATTESTATION_ID`, API URLs | `AttestationId` object, `Environment` enum | +| `packages/mobile-sdk-alpha/src/stores/protocolStore.ts` | Protocol data fetching, tree caching | `ProtocolDataStore.kt` | diff --git a/specs/SPEC-PERSON1-UI.md b/specs/SPEC-WEBVIEW-UI.md similarity index 100% rename from specs/SPEC-PERSON1-UI.md rename to specs/SPEC-WEBVIEW-UI.md