Implementing Decentralised Identity with ZK-SNARKS(Part-1)

Building On-chain Identity

Arun
Level Up Coding

--

Photo by Brett Jordan on Unsplash

Introduction to Decentralized Identity

In the digital age, our identities are often managed by centralized entities, leaving our personal data vulnerable and out of our direct control. Decentralized identity offers a paradigm shift: it empowers individuals by giving them ownership of their identity. This innovative approach not only enhances security but also returns control to the individual, ensuring that identity is both private and user-centric.

Problems with centralized identity

  1. Data Vulnerability: Centralized databases become prime targets for cyberattacks. A breach in one system can expose the personal data of millions, leading to potential misuse.
  2. Lack of User Control: In centralized systems, users typically have little say over how their data is used. Institutions or corporations decide the terms, often leading to unauthorized sharing or selling of personal data.
  3. Single Point of Failure: Centralized systems are vulnerable to both technical and administrative failures. If the system goes down, every user is affected, and if the administrative body makes an error, it can have wide-reaching consequences.
  4. Inefficiency and Cost: Centralized identity verification often requires manual processing or repeated verification across different platforms, resulting in both time and cost inefficiencies.
  5. Privacy Concerns: Centralized entities might have access to more information than necessary, leading to potential misuse or unwarranted surveillance.
  6. Interoperability Issues: Data stored in one centralized system may not be easily compatible or accessible with another, hindering seamless user experiences across platforms.

Understanding Zero-Knowledge Proofs (ZKPs):

At its essence, a zero-knowledge proof is a cryptographic method that allows one party (the prover) to prove to another party (the verifier) that a statement is true without revealing any specific information about the statement itself. Imagine proving that you are over 21 years old without providing your date of birth.

Zero-Knowledge Succinct Non-Interactive Arguments of Knowledge (zk-SNARKs) is a specific type of zero-knowledge proof that has three distinct qualities: zero-knowledge, succinctness, and non-interactive. Let’s break it down.

  1. Zero-Knowledge: Like all zero-knowledge proofs, zk-SNARKs enable one party to prove to another that a statement is true without revealing any information about the statement itself.
  2. Succinctness: zk-SNARKs are incredibly efficient. Both the proof and the verification process are short and quick, making it practical for blockchain applications where efficiency is a priority.
  3. Non-Interactive: The prover can create a proof without any interaction from the verifier. Once the proof is generated, any verifier can confirm its accuracy independently.

How it works

  1. Setup Phase: The first step in creating a zk-SNARK is the setup phase. This phase is crucial for generating the cryptographic keys used in the process. The setup generates a proving key (for the prover) and a verification key (for the verifier).
  2. Creating the Proof: The prover uses the proving key to produce proof. The prover takes the statement to be proven (let’s say you’re over 21), applies it to a mathematical function called a “circuit,” and generates a proof using their secret information and the proving key. Importantly, the proof doesn’t contain the secret (your age), but it does mathematically encapsulate the truth of the statement.
  3. Verification: The verifier uses the verification key from the setup phase to check the proof. The verification process is computationally light, making zk-SNARKs incredibly efficient for blockchain systems. If the proof checks out, the verifier can be certain that the statement is true, all without knowing the statement.

Implementation

Here we have it, our first step into decentralized identity with the Soul Bond Token (SBT). This contract is just version one. As I work on it, there will be improvements and added features. Bear with me here.

Storing Identity On-Chain using Soul Bond Tokens:

The contract is designed to manage decentralized identities using Soul Bond Tokens (SBTs) on the blockchain. At its core, the contract provides a mechanism for specific verified entities like the government to issue, or "mint", SBTs for users. Each token contains crucial identity parameters: a UID and cryptographic hashes of the user's name and age.

This system goes a step further by allowing additional entities, such as banks or educational institutions, to add profiles to a user’s SBT. For instance, a bank might add a profile reflecting a credit score, or a university might append a degree verification. These added profiles, represented as cryptographic hashes, maintain user privacy while still enabling verification. And with the integration of zero-knowledge proofs, it’s possible to verify the truthfulness of the data without revealing the actual information itself. In essence, theDIdentity contract offers a more structured approach to decentralized identity, blending the benefits of blockchain with modern cryptographic techniques.

SBT for decentralized identity

Deploy the contract and mint an identity for the user:

The provided JavaScript code delves into deploying and minting an identity for a user. Once deployed, we can mint an identity by hashing a unique user ID (UID), name, and date of birth (DoB) and then saving this information on-chain. Lastly, it features a specialized hashing function, poseidonHashthat uses the Poseidon hash algorithm, a zk-SNARK-friendly function, to hash given inputs securely.

const hre = require("hardhat");
const ethers = require("ethers");
const circomlibjs = require("circomlibjs");
const snarkjs = require("snarkjs");
const fs = require("fs");

// Deploy the contracts
async function deploy() {
const dIdentityContract = await hre.ethers.deployContract("DIdentity");
await dIdentityContract.waitForDeployment();
console.log("DIdentity deployed to:", dIdentityContract.target);
return dIdentityContract;
}

// Mint an Identity
async function mintIdentity(signer, name, DoB, dIdentityContract) {
const UID = ethers.sha256(ethers.toUtf8Bytes(signer.address + name + DoB));
const nameHash = ethers.sha256(ethers.toUtf8Bytes(signer.address + name));
const DoBHash = await poseidonHash([signer.address, DoB]);

const identity = {
UID: UID,
nameHash: nameHash,
dobHash: DoBHash,
}

const tx = await dIdentityContract.mint(signer.address, identity);
await tx.wait();
}

// Poseidon Hash
async function poseidonHash(inputs) {
const poseidon = await circomlibjs.buildPoseidon();
const poseidonHash = poseidon.F.toString(poseidon(inputs));
return poseidonHash;
}

Creating ZK-SNARK Circuits with Circom:

In this example, we use Circom to create the circuits for ZK-SNARKs. Since Circom is written in Rust, you’ll have to install the Rust environment.

curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh

Once Rust is installed, you need to build the Circom compiler.

git clone https://github.com/iden3/circom.git
cd circom
cargo build --release
cargo install --path circom

The Circomlib library has a ton of predefined circuits. This saves us the headache of writing our own circuits for complex things like hashing. The Snarkjs library allows us to do a trusted setup, and generate and verify proofs.

npm install circomlib
npm install snarkjs

Age Proof

The provided circuit describes a zero-knowledge proof mechanism for age verification without revealing the exact birth date. In the age of data privacy, we'd love to validate a user's age without revealing their exact birth date, and this code aims to do just that.

ZK-SNARK for age-proof

Here’s what each section does:

  1. Imports: The code starts by importing two essential circuit components from the circomlib library - Poseidon, a zk-SNARK-friendly hash function, and a set of comparators for numeric checks.
  2. Template AgeProof: This is where the magic happens. (Format this better)
    - Inputs: It takes in the doBTimestamp (a timestamp of the user's date of birth), address (Ethereum address of the user), currentTimestamp (the current time for age calculation), ageThreshold (the age limit we're checking against, say 18 years converted to seconds), and hash (a combination of the address and DoB for validation).
    - Age Calculation: It computes the user’s age by subtracting the doBTimestamp from the currentTimestamp.
    - Age Check: Using the LessThan comparator, the code ensures the computed age surpasses the ageThreshold. This is our gatekeeper, ensuring the age requirement is met.
    - Hash Validation: The Poseidon hashing mechanism is then employed to verify the provided hash against the computed hash of the user's address and DoB. This ensures that the data being presented hasn't been tampered with.
  3. Component Main: The main component is an instantiation of the AgeProof template, taking in the public parameters needed to generate or validate the proof.

In essence, this circuit can be used to confirm if someone’s age exceeds a specific threshold (like being over 18) without revealing their actual age, making it a powerful tool in the realm of decentralized identity and privacy.

Credit Proof

The below code is designed to determine the creditworthiness of a user. The bank can set a profile with the hash of the credit score stored in the user profile.

async function createProfile(signer, address, creditScore, dIdentityContract) {
const hash = await poseidonHash([address, creditScore]);
const profile = {
entity: "Bank Credit Score",
dataHash: hash,
timestamp: Date.now(),
}
const tx = await dIdentityContract.connect(signer).setProfile(address, profile);
await tx.wait();
}

The below circuit helps the user prove that their credit score is within a certain range.

ZK-SNARK for credit proof

Let’s break down this circuit:

  1. Imports: We’re just using some handy tools from the circomlib library for hashing and numeric checks.
  2. Template CreditProof:
    - Inputs: We’ve got the user’s creditScore, a minCreditScore and maxCreditScore to define the acceptable range, the user's address, and a hash.
    - Range Check: The code checks if the given creditScore is within the set range.
    - Hash Check: We’re using Poseidon, a fancy name for a hash function, to double-check that the data hasn’t been tampered with.
  3. Component Main: It’s the “public interface”, meaning these are the bits others will use to communicate with our circuit.

In a nutshell, using this zk-SNARK circuit, lenders in the DeFi space can validate if someone meets their credit requirements without ever knowing the person’s exact credit score, showcasing the blend of privacy and trust in decentralized systems.

Compiling the Circuits

At first, we need to compile the circom circuit and generate the wasm and r1cs files for the circuit.

circom age_proof.circom --wasm --r1cs -o ./build
circom credit_proof.circom --wasm --r1cs -o ./build

We need a proving key to generate the proof for the circuit. We need a ptau file to generate a proving key for this circuit. We can either generate a new one using Snarkjs or download a pre-generated one. In this example, we’ll download a pre-generated ptau file.

wget https://hermez.s3-eu-west-1.amazonaws.com/powersOfTau28_hez_final_12.ptau

We can use this ptau file to generate a proving key for our circuits.

npx snarkjs groth16 setup build/age_proof.r1cs powersOfTau28_hez_final_12.ptau circuit_age.zkey
npx snarkjs groth16 setup build/credit_proof.r1cs powersOfTau28_hez_final_12.ptau circuit_credit.zkey

Creating Zero Knowledge Proofs (ZKPs)

Let’s delve into a snippet that deals with creating a zero-knowledge proof for credit scores. The below function accepts four parameters — signer, creditScore, minCreditScore, and maxCreditScore. The signer is the user for whom the proof is being created, and their credit score along with the acceptable minimum and maximum scores are passed as arguments. First, it calculates a hash of the signer's address and their credit score using Poseidon, a zk-SNARK-friendly hash function. This hash is then fed, along with the other parameters, into the zk-SNARK circuit snarkjs.groth16.fullProve to generate the proof and public signals for the given arguments. This proof and public signals are used for on-chain verification, thereby ensuring that your creditworthiness can be proven without revealing the score itself.

// Create a credit proof
async function createCreditProof(signer, creditScore, minCreditScore, maxCreditScore) {
const hash = await poseidonHash([signer.address, creditScore]);
const {proof, publicSignals} = await snarkjs.groth16.fullProve(
{ creditScore: creditScore, minCreditScore: minCreditScore, maxCreditScore: maxCreditScore, address: signer.address, hash: hash },
"build/credit_proof_js/credit_proof.wasm",
"circuit_credit.zkey");

const creditProof = proof;
const creditPublicSignals = publicSignals;
return { creditProof, creditPublicSignals };
}

In the same vein as our credit score verification, we’ve also implemented a function createAgeProof that performs a similar function—but this time for age verification. Imagine you want to confirm that someone is over 18 for a service you offer, but you don't want to know their exact age.

// Create age proof
async function createAgeProof(signer, doBTimestamp, currentTimestamp, ageThreshold) {
const hash = await poseidonHash([signer.address, doBTimestamp]);
const { proof, publicSignals } = await snarkjs.groth16.fullProve(
{ doBTimestamp: doBTimestamp, address: signer.address, currentTimestamp: currentTimestamp, ageThreshold: ageThreshold, hash: hash },
"build/age_proof_js/age_proof.wasm",
"circuit_age.zkey");

return { proof, publicSignals };
}

The createAgeProof the function takes in four parameters: signer, doBTimestamp, currentTimestamp, and ageThreshold. The signer is the user's Ethereum address, doBTimestamp is the timestamp for the user's date of birth, currentTimestamp is the current time, and ageThreshold is the minimum age requirement (all in seconds). First off, we use Poseidon to hash the signer's address and the date of birth timestamp. Next, the function takes this hash, along with the other data points, and creates a zk-SNARK proof using snarkjs.groth16.fullProve. Similar to the previous function, we use the proof and public signals for on-chain verification.

Verification of ZKPs

We need to create verification keys from the proving key to verify the generated proof.

npx snarkjs zkey export verificationkey circuit_age.zkey verification_key_age.json
npx snarkjs zkey export verificationkey circuit_credit.zkey verification_key_credit.json

Verifying Age proof

When it comes to putting our age-proving function to the test, we need a way to verify these zero-knowledge proofs. That’s exactly what the verifyAgeProof function is designed to do. This function accepts four parameters: address, proof, publicSignals, and dIdentityContract.

// Verify Age zk-proof
async function verifyAgeProof(address, proof, publicSignals, dIdentityContract) {
const id = await dIdentityContract.getID(address);
const vKey = JSON.parse(fs.readFileSync("verification_key_age.json"));
const res = await snarkjs.groth16.verify(vKey, publicSignals, proof);
return (res && (id.dobHash == publicSignals[3]));
}

First, it fetches the decentralized identity of the user associated with the provided address by invoking the getID method of our dIdentityContract. This gives us a user's identity information, including their hashed date of birth (dobHash).

Next up, the function reads the verification key from a JSON file. You might be wondering why we need a verification key. Well, it’s like the public part of a lock-and-key system for zk-SNARKs, ensuring that the proof can be publicly verified.

Now comes the moment of truth: the function used snarkjs.groth16.verify to verify the provided zk-SNARK proof against the publicSignals and the verification key. If the verification is successful, we're not done just yet! We also make sure that the hashed date of birth in the identity contract matches one of the publicSignals.

So, if everything lines up — the zk-SNARK proof is verified and the date of birth hash matches — we return true, confirming the user's age without ever revealing what it is.

Verifying Credit Proof

// Verify Credit zk-proof
async function verifyCreditProof(profiler, address, proof, publicSignals, dIdentityContract) {
const profile = await dIdentityContract.getProfile(profiler, address);
const dataHash = profile.dataHash;
const vKey = JSON.parse(fs.readFileSync("verification_key_credit.json"));
const res = await snarkjs.groth16.verify(vKey, publicSignals, proof);
return (res && (dataHash == publicSignals[3]));
}

This function takes in five arguments, including the profiler (eg, bank), the user's address, a zk-SNARK proof, publicSignals, and the decentralized identity contract (dIdentityContract).

The function starts by fetching the user’s profile data from the smart contract and then reads the verification key from a stored JSON file. With these pieces in place, it calls on the snarkjs.groth16.verify function to authenticate the zk-SNARK proof. If the proof holds up and matches the hash stored in the user's profile, the function returns true, confirming that the credit data is both authentic and matches the stored record. It’s a secure yet straightforward way to maintain the integrity of financial data on a decentralized network.

Conclusion

So, that’s where we are with this project for now. I’m working on expanding it to be a more complete decentralized identity system on-chain. It’s a step-by-step process, and there’s a lot more to come. I’ll continue to blog about my progress, so stay tuned for Part 2.

You can find this project’s GitHub repository here.

If you want to chat about web3, zk proofs, quantum computing, or AI, feel free to hit me up on Twitter. I’m always open to a good tech discussion.

--

--