Introduction
On June 17th, 2025, zkSecurity was engaged to perform an audit of the Self project.
The assessment lasted for one week and focused on the implemented changes to support european ID MRTD cards, the implementation of OFAC checks and the changes to the smart contracts. The audit was conducted on the self repository at commit 4f18c75
and was carried out by two consultants.
Scope
The audit focused on the following subset of the Self project:
-
The circuits that support European ID MRTD cards, which includes the registration of documents and the disclosure of information. In particular the emphasis was on the following circuits:
circuits/register/register_id.circom
circuits/disclose/vc_and_disclose_id.circom
circuits/utils/passport/disclose/disclose_id.circom
-
The implementation of OFAC checks for both IDs and passports:
- All circuits in
circuits/utils/passport/ofac/
- All circuits in
- The proof of non-inclusion using sparse Merkle trees (SMT) used by the OFAC checks:
circuits/utils/crypto/merkle-trees/smt.circom
- Changes to the smart contracts, in particular the smart contracts belonging to the V2.
contracts/contracts/IdentityVerificationHubImplV2.sol
contracts/contracts/libraries/GenericFormatter.sol
contracts/contracts/libraries/CustomVerifier.sol
contracts/contracts/libraries/CircuitAttributeHandlerV2.sol
contracts/contracts/abstract/SelfVerificationRoot.sol
contracts/contracts/constants/CircuitConstantsV2.sol
contracts/contracts/constants/AttestationId.sol
contracts/contracts/interfaces/IIdentityVerificationHubV2.sol
contracts/contracts/interfaces/ISelfVerificationRoot.sol
contracts/contracts/registry/IdentityRegistryIdCardImplV1.sol
Note: zkSecurity recently completed a broad review of the Self OpenPassport monorepo, so this engagement concentrated solely on the newly added functionality outlined above. In our prior assessment, we recommended a more exhaustive examination of the entire codebase (given the time constraints and complexity of certain findings) and we are now working with the client to schedule further evaluations.
Overview of the scope
Overview of European ID MRTD cards
The front of a European ID Machine Readable Travel Document (MRTD) card typically adheres to the following design (from “Specifications for TD1 Size Machine Readable Official Travel Documents (MROTDs)”):
The back of the card contains the Machine Readable Zone (MRZ) which holds all the data displayed on the front of the card as well as optional data that the issuer can add. See below for a detailed breakdown of what the structure of this data looks like (Appendix from the specification):
This section is referred to as the DG1 field and contains all the information that users can selectively reveal in the circuit.
Changes in the circuits to support European ID MRTD cards
In order to support EU ID cards in addition to passports, the Self project has refactored the circuits to support the new ID format. In Self, the process of handling ID information is split into three steps:
- Registration of a DSC: a user can provide a proof that a DSC certificate is valid and can store the certificate information on-chain.
- Registration: a user can prove that they own an ID signed by a valid DSC and can store a hiding commitment to the ID on-chain.
- Disclosure: a user can prove that they own an ID stored on-chain, and that the ID satisfies certain properties, such as its holder’s age being greater than some amount, and not being associated with a sanctioned entity according to the OFAC list.
EU ID cards share most of their logic with Passports, but there are minor differences:
- The DG1 field has a different structure and length: 93 bytes for passports and 95 bytes for EU IDs.
- The OFAC checks are different: both check that the hash values of
(name, date of birth)
and(name, year of birth)
are not in the OFAC list, but the passport can additionally check that the hash value of(passport number, nationality)
is not in the OFAC list. - The attestation ID is different: 1 for passports and 2 for EU IDs. This is done to distinguish between the two different types of IDs, since the commitments are stored in the same Merkle tree.
To accommodate these differences, the Self project has created the following new circuits for EU IDs:
circuits/register_id/register_id.circom
circuits/disclose/vc_and_disclose_id.circom
circuits/utils/passport/disclose/disclose_id.circom
circuits/utils/passport/ofac/ofac_name_dob_id.circom
circuits/utils/passport/ofac/ofac_name_yob_id.circom
Note that there is no separate circuit for the dsc.circom
circuit, which is used to verify the DSC certificate, as there is no difference between passports and EU IDs in this case.
Circuits implementing OFAC checks
All the OFAC checks are implemented using a sparse Merkle tree (SMT) data structure, for which we provide a brief overview below.
The SMT is used as a key-value store, and supports proving both inclusion and non-inclusion of a given key in the tree.
In the case of OFAC checks, the values are always set to 1
, since the only information stored is whether a key is present in the tree or not.
The trees are computed off-circuit by the Self team, taking in input the public list of OFAC sanctioned entities. There are three different trees used to match different data of the ID or passport:
- Matching the passport number and the nationality: this provides an absolute and high confidence match (only for passports).
- Matching the name and date of birth: this provides a high probability match (for both passports and IDs).
- Matching the name and year of birth: this provides a partial match (for both passports and IDs).
For each sanctioned entity the relevant information is extracted by the Self team, and hashed with Poseidon to create a key
that is then stored in the SMT.
The resulting tree, and in particular its root, is then published.
In the disclose circuit, the relevant information is extracted from the ID or passport, hashed, and then, to prove non-inclusion in the OFAC list, an SMT non-inclusion proof must be provided to the circuit, which ensures that the ID is not associated with a sanctioned entity.
As an example, for the passport number and nationality check, the following template is used:
template OFAC_PASSPORT_NUMBER(nLevels) {
signal input dg1[93];
signal input smt_leaf_key;
signal input smt_root;
signal input smt_siblings[nLevels];
component poseidon_hasher = Poseidon(12);
for (var i = 0; i < 9; i++) { // passport number
poseidon_hasher.inputs[i] <== dg1[49 + i];
}
for (var i = 0; i < 3; i++) { // nationality
poseidon_hasher.inputs[9 + i] <== dg1[59 + i];
}
signal output ofacCheckResult <== SMTVerify(nLevels)(poseidon_hasher.out, smt_leaf_key, smt_root, smt_siblings, 0);
}
From the data group 1 (DG1) of the ID or passport, the passport number is extracted from bytes 49 to 57.
Then the nationality is extracted from bytes 59 to 61.
The ASCII values are hashed using the Poseidon hash function, and the resulting hash is used as the lookup key in the SMT.
The SMTVerify
template is then used, configured in non-inclusion mode, to verify that the provided Merkle proof is a valid non-inclusion proof for the given key.
Sparse Merkle tree overview
The base implementation of the SMT is based on zk-kit and is used off-circuit to compute the tree root and the Merkle proofs. A sparse Merkle tree is a data structure that allows for an authenticated key-value store: in most implementations the tree is complete, meaning that every possible key is included as a leaf in the tree. In zk-kit’s implementation, this structure is more similar to an uncompacted trie, where the leaves can be at different depths.
The idea is that a leaf can be either:
- Zero, meaning that no key is present having a binary prefix corresponding to the leaf’s position.
- Non-zero, meaning that a key is present in the tree having a binary prefix corresponding to the leaf’s position: in this case the leaf is the hash of the key and the value associated with it.
The tree supports both inclusion and non-inclusion proofs.
- To prove that a key is included in the tree, the prover needs to provide the opening to the corresponding leaf and the correct authentication path to the root.
- To prove that a key is not included in the tree, the prover needs to provide the opening to the closest leaf to the key and the correct authentication path to the root. Due to the way the tree is constructed, if the closest leaf opening does not match the key, but matches the key’s prefix up until the leaf’s depth, then the key is not included in the tree. In other words, the leaves are stored in the tree at the position of the minimal binary prefix that is not shared with any other key in the tree.
Overview of V2 smart contracts
The V2 system represents an architectural improvement over V1, introducing more structured configuration management, enhanced verification flows, and better separation of concerns between the hub and registry layers. The contracts support both passport and EU ID card attestations, with extensible verifier mappings for other cryptographic signature schemes.
The V2 Self contracts center around IdentityVerificationHubImplV2
, which continues to manage interactions between users’ zero-knowledge proofs and the on-chain identity registry. The contract follows an upgradeable proxy pattern using ERC-7201 storage.
V2 introduces a structured configuration system where verification rules are stored using deterministic config IDs generated via SHA256 hashing. Configurations are set through setVerificationConfigV2()
. A sample config is defined as follows:
struct VerificationConfigV2 {
bool olderThanEnabled;
uint256 olderThan;
bool forbiddenCountriesEnabled;
uint256[4] forbiddenCountriesListPacked;
bool[3] ofacEnabled;
}
The hub integrates with multiple circuit verifiers organized by attestation type (passport, ID) and signature algorithm. Circuit constants are managed through CircuitConstantsV2
which provides indices for extracting specific values from proof outputs.
External applications integrate through the SelfVerificationRoot
abstract contract, which provides a standardized interface for proof verification. Applications call verifySelfProof()
and receive callbacks through onVerificationSuccess()
.
The IdentityRegistry contracts are the on-chain ledger for user identity commitments. They use a Sparse Merkle Tree (SMT) to store and prove the existence of identity commitments.
Two logic flows are particularly relevant in how they interact with all the components of the V2 system. First is the need to register committments, and inherently the circuit. The second is an external app (the SelfVerificationRoot
) wanting to verify a proof. The external app communicates exclusively with the Hub, while the Hub relays its demands to registry and the ZK layer.
The ZK layer consists of both on-chain and off-chain components. On-chain, we conduct _basicVerification
, accomplishing the following:
1. Scope and user identifier checks
2. Merkle root validation against registry
3. Date validation
4. Groth16 ZK proof verification