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

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

53 KiB
Raw Permalink Blame History

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

object BN254 {
    val PRIME = "21888242871839275222246405745257275088548364400416034343698204186575808495617".toBigInteger()
}

Algorithm

/**
 * Poseidon hash function over BN254 field.
 *
 * @param inputs 116 field elements
 * @return Single field element hash
 */
fun poseidon(inputs: List<BigInteger>): 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):

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

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>): BigInteger = poseidon(inputs)
// ... poseidon1 through poseidon16

flexiblePoseidon and packBytesAndPoseidon

/**
 * Dynamically selects Poseidon variant based on input count.
 */
fun flexiblePoseidon(inputs: List<BigInteger>): 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<Int>): 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>): 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

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<FRADUPONT<<JEAN<<<<<<<<<<<<<<<<<<<<<<<<<<<".map { it.code }
        val result = packBytesAndPoseidon(mrzBytes)
        assertEquals(EXPECTED_MRZ_HASH, result)
    }

    @Test
    fun `poseidon field overflow wraps correctly`() {
        // Input larger than field prime should be reduced mod p
        val large = BN254.PRIME + BigInteger.ONE
        val result = poseidon2(large, BigInteger.ZERO)
        val expected = poseidon2(BigInteger.ONE, BigInteger.ZERO)
        assertEquals(expected, result)
    }
}

Test vector generation strategy: Write a small TypeScript script that computes all needed test vectors and outputs them as Kotlin constants. Run once, embed in test files.


2. Byte Packing Utilities

Port of common/src/utils/bytes.ts.

object BytePacking {
    const val MAX_BYTES_IN_FIELD = 31  // 31 bytes < 254 bits (BN254 field)

    /**
     * Pack a byte array into field elements (31 bytes each).
     * Each chunk is packed little-endian: byte[0] + byte[1]*256 + byte[2]*65536 + ...
     *
     * Port of packBytesArray from common/src/utils/bytes.ts
     */
    fun packBytesArray(unpacked: List<Int>): List<BigInteger> {
        val result = mutableListOf<BigInteger>()
        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<String> {
        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<Int> {
        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<BigInteger> {
        return (0 until numBits).map { i ->
            (value shr i) and BigInteger.ONE
        }
    }

    /**
     * Convert byte array to decimal string via BigInt.
     */
    fun bytesToBigDecimal(bytes: List<Int>): String {
        var result = BigInteger.ZERO
        for (byte in bytes) {
            result = (result shl 8) + (byte and 0xFF).toBigInteger()
        }
        return result.toString()
    }
}

Testing

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.

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<Int>, maxShaBytes: Int): Pair<List<Int>, 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<Int>, maxShaBytes: Int): Pair<List<Int>, 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>, Int) -> Pair<List<Int>, Int> {
        return when (hashAlgorithm.lowercase()) {
            "sha1", "sha224", "sha256" -> ::shaPad
            "sha384", "sha512" -> ::sha384_512Pad
            else -> throw IllegalArgumentException("Unsupported hash algorithm: $hashAlgorithm")
        }
    }
}

Testing

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.

/**
 * 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<MutableList<BigInteger>> = 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<BigInteger>()
        val pathIndices = mutableListOf<Int>()
        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<BigInteger>,
    val pathIndices: List<Int>,
)

generateMerkleProof wrapper (matches TypeScript API)

/**
 * 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<BigInteger>,
    val path: List<Int>,
    val leafDepth: Int,
)

Testing

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.

/**
 * 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>) -> BigInteger,
    private val bigNumbers: Boolean = true,
) {
    private val nodes: MutableMap<String, BigInteger> = mutableMapOf()
    private val entries: MutableMap<String, Pair<BigInteger, BigInteger>> = 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>) -> BigInteger, serialized: String): SparseMerkleTree {
            // Deserialize from JSON
        }
    }
}

data class SmtProof(
    val entry: Pair<BigInteger, BigInteger>,         // (key, value) being proven
    val matchingEntry: Pair<BigInteger, BigInteger>?, // Closest existing entry (non-membership)
    val siblings: List<BigInteger>,
    val root: BigInteger,
    val membership: Boolean,                          // true = member, false = non-member
)

generateSMTProof wrapper

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

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<BigInteger>, dob: List<BigInteger>): 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>): 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<BigInteger>, yob: List<BigInteger>): BigInteger {
        val nameHash = getNameLeaf(name)
        val yobHash = poseidon(yob.take(2))  // poseidon2
        return poseidon3(nameHash, yobHash, BigInteger.ZERO)
    }

    fun getPassportNumberAndNationalityLeaf(
        passportNumber: List<BigInteger>,
        nationality: List<BigInteger>,
    ): BigInteger {
        // poseidon12: 9 passport digits + 3 nationality chars
        return poseidon(passportNumber.take(9) + nationality.take(3))
    }

    fun getCountryLeaf(countryFrom: List<BigInteger>, countryTo: List<BigInteger>): BigInteger {
        return poseidon(countryFrom.take(3) + countryTo.take(3))  // poseidon6
    }
}

7. MRZ Formatter

Port of formatMrz from common/src/utils/passports/format.ts.

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<Int> {
        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

class MrzFormatterTest {
    @Test
    fun `format passport MRZ adds correct TLV tags`() {
        val mrz = "P<FRADUPONT<<JEAN<PIERRE<<<<<<<<<<<<<<<<<<<<<12345678<6FRA8001014M3001011<<<<<<<<<<<<<<02"
        assertEquals(88, mrz.length)
        val formatted = MrzFormatter.format(mrz)
        assertEquals(93, formatted.size)  // 5 header bytes + 88 MRZ bytes
        assertEquals(0x61, formatted[0])  // DG1 tag
        assertEquals(0x5F, formatted[2])  // MRZ_INFO tag
        assertEquals(0x1F, formatted[3])
    }
}

8. Commitment Generator

Port of generateCommitment from common/src/utils/passports/passport.ts.

object CommitmentGenerator {
    /**
     * Generate commitment hash for passport/ID document.
     *
     * commitment = poseidon5(secret, attestation_id, dg1_packed_hash, eContent_packed_hash, dsc_hash)
     *
     * @param secret User's secret (decimal string)
     * @param attestationId "1" (passport), "2" (ID card), "3" (aadhaar), "4" (kyc)
     * @param passportData Parsed passport with MRZ, eContent, parsed certificates
     * @return Commitment as BigInteger
     */
    fun generate(
        secret: String,
        attestationId: String,
        mrz: String,
        eContent: ByteArray,
        eContentHashFunction: String,
        dscParsed: CertificateData,
        cscaParsed: CertificateData,
    ): BigInteger {
        // 1. Hash formatted MRZ
        val mrzBytes = MrzFormatter.format(mrz)
        val dg1PackedHash = packBytesAndPoseidon(mrzBytes)

        // 2. Hash eContent with the document's hash algorithm, then pack
        val eContentShaBytes = sha(eContentHashFunction, eContent)
        val eContentPackedHash = packBytesAndPoseidon(eContentShaBytes.map { it.toInt() and 0xFF })

        // 3. Compute DSC tree leaf
        val dscHash = DscLeaf.getLeafDscTree(dscParsed, cscaParsed)

        // 4. Poseidon-5 commitment
        return poseidon5(listOf(
            secret.toBigInteger(),
            attestationId.toBigInteger(),
            dg1PackedHash,
            eContentPackedHash,
            dscHash,
        ))
    }
}

9. DSC Tree Leaf

Port of getLeafDscTree from common/src/utils/trees.ts.

object DscLeaf {
    /**
     * Compute DSC tree leaf from parsed DSC and CSCA certificates.
     *
     * leaf = poseidon2(dscLeaf, cscaLeaf)
     * where:
     *   dscLeaf = poseidon2(packBytesAndPoseidon(shaPad(tbsBytes, maxDscBytes)), tbsLength)
     *   cscaLeaf = poseidon2(packBytesAndPoseidon(zeroPad(tbsBytes, maxCscaBytes)), tbsLength)
     */
    fun getLeafDscTree(dscParsed: CertificateData, cscaParsed: CertificateData): BigInteger {
        val dscLeaf = getLeaf(dscParsed, CertType.DSC)
        val cscaLeaf = getLeaf(cscaParsed, CertType.CSCA)
        return poseidon2(dscLeaf, cscaLeaf)
    }

    private fun getLeaf(cert: CertificateData, type: CertType): BigInteger {
        val tbsBytes = cert.tbsBytes
        val maxBytes = if (type == CertType.DSC) MAX_DSC_BYTES else MAX_CSCA_BYTES

        val padded = if (type == CertType.DSC) {
            // DSC: SHA-pad the TBS bytes
            val (paddedBytes, _) = ShaPad.pad(cert.hashAlgorithm)(tbsBytes, maxBytes)
            paddedBytes
        } else {
            // CSCA: Zero-pad the TBS bytes
            val result = tbsBytes.toMutableList()
            while (result.size < maxBytes) result.add(0)
            result
        }

        val hash = packBytesAndPoseidon(padded)
        return poseidon2(hash, tbsBytes.size.toBigInteger())
    }

    private enum class CertType { DSC, CSCA }
}

10. ASN.1 / X.509 Certificate Parser

Port of parseCertificateSimple from common/src/utils/certificate_parsing/.

This is the most complex non-crypto component. It needs a minimal ASN.1 DER parser.

/**
 * Minimal ASN.1 DER parser for X.509 certificate extraction.
 *
 * Only parses what we need:
 *   - TBS (To-Be-Signed) bytes
 *   - Public key (RSA modulus/exponent, ECDSA x/y/curve)
 *   - Signature algorithm OID
 *   - Subject Key Identifier (SKI) and Authority Key Identifier (AKI)
 *   - Validity dates
 *
 * NOT a general-purpose ASN.1 library — just enough for passport certificates.
 */
object Asn1Parser {
    /**
     * Parse a DER-encoded byte array into an ASN.1 element tree.
     */
    fun parse(der: ByteArray): Asn1Element { /* ... */ }

    /**
     * Extract TBS (To-Be-Signed) bytes from a certificate DER.
     * The TBS is the first SEQUENCE inside the outer SEQUENCE.
     */
    fun extractTbs(certDer: ByteArray): ByteArray { /* ... */ }
}

sealed class Asn1Element {
    data class Sequence(val elements: List<Asn1Element>) : 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()
}
/**
 * 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

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

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.

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<String, String>? = 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.)

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

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:

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

// 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<FRA...", output: formatMrz("P<FRA...") },
  ],
  commitment: [
    {
      secret: "12345",
      attestationId: "1",
      mrz: "P<FRA...",
      output: generateCommitment("12345", "1", mockPassport).toString()
    },
  ],
  certificates: [
    { pem: "-----BEGIN CERTIFICATE-----...", tbsBytes: [...], ski: "...", aki: "..." },
  ],
}

console.log(JSON.stringify(vectors, null, 2))

Store output in packages/kmp-sdk/shared/src/commonTest/resources/test-vectors.json. Load in tests.

Test Categories

Category Count What's Verified
Poseidon hash ~60 tests All 16 variants, edge cases (zero, max field, overflow)
Byte packing ~15 tests packBytes, splitToWords, roundtrips
SHA padding ~10 tests SHA-256 and SHA-384 padding correctness
MRZ formatting ~5 tests 88-char passport, 90-char ID card, error cases
LeanIMT ~15 tests Import, indexOf, generateProof, root verification
SMT ~10 tests Import, membership proof, non-membership proof
Leaf generators ~10 tests Name, DOB, country, passport number leaves
ASN.1 parser ~10 tests Known DER structures, edge cases
Certificate parser ~15 tests RSA, ECDSA, RSA-PSS, SKI/AKI extraction, TBS bytes
Passport parser ~10 tests Metadata extraction for various algo combos
Commitment ~10 tests Known passport + secret → expected commitment
Nullifier ~5 tests Known passport → expected nullifier
DSC leaf ~5 tests Known certificates → expected leaf hash
Selector ~10 tests All attribute combos for passport and ID card
Total ~190 tests

Cross-Platform Verification

Run the same commonTest suite on all targets:

./gradlew :shared:jvmTest          # Fast iteration
./gradlew :shared:iosSimulatorArm64Test  # Verify iOS
# Future: ./gradlew :shared:jsTest    # Verify browser extension compatibility

Dependencies

Build Dependencies

commonMain.dependencies {
    implementation(libs.kotlinx.serialization.json)  // Already present
    // No other dependencies — everything is pure Kotlin
}

commonTest.dependencies {
    implementation(libs.kotlin.test)
    implementation(libs.kotlinx.coroutines.test)  // Already present
}

No additional library dependencies. This is intentionally zero-dependency (beyond kotlinx-serialization for JSON parsing of tree data). SHA hashing can be implemented in pure Kotlin (~200 lines for SHA-256) to avoid any platform dependency.

If SHA performance matters, add expect/actual later — but for correctness-first, pure Kotlin is simpler.

Spec Dependencies

  • None — this is the leaf dependency
  • SPEC-PROVING-CLIENT.md depends on this (chunks 4D, 4E, 4F use Poseidon, trees, commitment)
  • SPEC-MINIPAY-SAMPLE.md depends on this indirectly (via proving client)
  • Future browser extension depends on this directly

Key TypeScript Reference Files

TypeScript File What to Port Kotlin Target
common/src/utils/hash.ts Poseidon wrappers, SHA, packBytesAndPoseidon hash/ package
common/src/utils/bytes.ts packBytes, splitToWords, hexToDecimal math/BytePacking.kt
common/src/utils/shaPad.ts SHA-256 and SHA-384 padding hash/ShaPad.kt
common/src/utils/trees.ts LeanIMT operations, SMT operations, leaf generators, proof generation trees/ package
common/src/utils/passports/passport.ts generateCommitment, generateNullifier, formatMrz passport/ package
common/src/utils/passports/format.ts formatMrz, DG1 formatting passport/MrzFormatter.kt
common/src/utils/passports/passport_parsing/parsePassportData.ts Metadata extraction passport/PassportDataParser.kt
common/src/utils/certificate_parsing/parseCertificateSimple.ts X.509 parsing certificate/X509CertificateParser.kt
common/src/utils/certificate_parsing/oids.ts OID resolution certificate/OidResolver.kt
common/src/constants/constants.ts Tree depths, max sizes, attestation IDs constants/Constants.kt
common/src/constants/skiPem.ts SKI → CSCA PEM mapping constants/SkiPem.kt
poseidon-lite (npm) Poseidon algorithm + round constants hash/Poseidon.kt, hash/PoseidonConstants.kt
@openpassport/zk-kit-lean-imt (npm) LeanIMT data structure trees/LeanIMT.kt
@openpassport/zk-kit-smt (npm) Sparse Merkle Tree trees/SparseMerkleTree.kt