A zero-knowledge credential verification system. Prove your academic qualifications to employers without revealing your identity, wallet, or transcript.
Kushal N · BTech Mathematics and Computing, 2nd Year · IIT Guwahati
Currently, employers face a lengthy and inaccurate verification process. They have to manually verify certificates with universities, dealing with slow responses and inaccurate records. It is expensive and incredibly tedious for the companies.
Applying to different companies in different domains forces you to hand over your entire transcript. If you are applying to a new company while already employed elsewhere, you don't want your transcript stuck in a career network where your current employer might see it.
Ethereum is public. Paying gas to submit a credential proof permanently links your wallet to that identity. Furthermore, traditional wallets rely on ECDSA, making them vulnerable to future quantum computers.
Employers need verified truth. Students need verifiable privacy. The current system forces a choice between the two. It should not. We try to solve this...
The proof is validated by semaphore.validateProof() on-chain. It is not a policy or a promise — it is an arithmetic constraint that cannot be forged without breaking SHA-256.
isDegree flag differentiates course vs. degree groupssemaphore.createGroup() and semaphore.addMember()linkCourseToDegree()verifyCredential()verifyBatch() returns bool[]approveUniversity()verifyBatch() via Kohaku's EthersSignerAdapter| Function / Interface | Location | Purpose |
|---|---|---|
| semaphore.createGroup(address) | CredentialIssuer.sol | Called in createCourse() and createDegree(). Deploys a new anonymous Merkle group on Semaphore v4 and returns its groupId. |
| semaphore.addMember(groupId, commitment) | CredentialIssuer.sol | Called in issueCredential(). Inserts a student's identity commitment into the course's Merkle tree, making them a group member without recording who they are. |
| semaphore.removeMember(groupId, commitment, proofSiblings) | CredentialIssuer.sol | Called in revokeCredential(). Removes a commitment using a Merkle sibling path, effectively revoking the credential. |
| semaphore.validateProof(groupId, proof) | CredentialVerifier.sol | Called inside verifyCredential() and wrapped in a try/catch inside verifyBatch(). Validates the Groth16 proof on-chain by checking the Merkle root and nullifier constraints. |
| new Identity(seed) | identity-vault / Student Portal | Creates a student's Semaphore identity client-side. The seed is derived deterministically from the student's MetaMask signature via SHA-256, so the same identity is always recovered on login. |
| generateProof(identity, group, message, scope) | Student Portal (browser) | Called when the student clicks "Generate Proof." Runs the Groth16 witness computation client-side using the Semaphore WASM circuit and returns the proof object. |
| ISemaphore.SemaphoreProof | CredentialVerifier.sol | The on-chain type definition for a submitted proof. Contains merkleTreeDepth, merkleTreeRoot, nullifier, message, scope, and points. |
Each proof generates a unique Nullifier Hash — a deterministic but opaque function of the student's secret and the job application scope. The contract records every used nullifier in usedNullifiers[nullifier]. Submitting the same proof a second time is rejected with "Nullifier already used" without ever revealing who submitted the first one.
Integrated into the Employer Verification portal. Because this is an alpha package, we manually encode the transaction payload and submit via Kohaku's EthersSignerAdapter instead of using standard ethers contract wrappers.
Each student's Semaphore identity is designed to be rooted in a CRYSTALS-Dilithium post-quantum key pair. The identity vault wrapper is production-ready; only the key generator falls back to a deterministic mock due to the package not being publicly available as an npm package yet.
Instead of relying on complex ERC-4337 Paymasters or Railgun shielded transactions to hide the student's wallet, we completely decoupled proof generation from proof submission. The student generates the ZK proof off-chain in the browser (costs 0 gas). The employer submits the payload on-chain and pays the gas. The student never touches the blockchain, achieving perfect anonymity.
Problem: Proof scope was computed as Buffer.from(jobId+"-"+groupId).toString('hex').slice(0,16). Since the job ID was exactly 8 chars (16 hex), the slice removed the groupId entirely — every proof in a bundle had the same scope and the same nullifier, causing on-chain replay reverts.
Fix: Replaced slice with BigInt('0x' + ethers.id(`${groupId}-${jobId}`).slice(2,18)) — a Keccak256 hash guarantees unique scopes per course per job.
Problem: Issuing credentials for multiple courses fired all issueCredential() calls in parallel. MetaMask signed all of them with the same nonce — all but the first failed on-chain.
Fix: Switched to sequential execution with await tx.wait() between each call, with a live progress indicator: "Issuing 2 / 3 — Please confirm in MetaMask."
Problem: The verify UI only checked whether the transaction succeeded overall. For a 3-course bundle, any unselected courses were marked "not verified" even if valid proofs existed for them — creating false negatives for the employer.
Fix: Redesigned to auto-detect single vs. bundle proof JSONs and iterate the returned bool[] array per course, showing individual pass/fail status for each credential.
Problem: After switching wallets in MetaMask, the University and Admin portals kept showing the old account's status. The accountsChanged event fired, but BrowserProvider.listAccounts() returned the stale cached address.
Fix: Updated the handler to read the new address directly from the event payload (accounts[0]) and pass it straight into the status check, bypassing the provider cache.
usedNullifiers check and reverts with "Nullifier already used."groupId. Reverts with "Invalid course."points[] value in the proof before submission. semaphore.validateProof() reverts on the arithmetic check.createCourse() → reverts AccessControlrequestRegistration() twice → reverts "Already registered"approveUniversity() for unapproved wallet → grants UNIVERSITY_ROLE correctly| Contract | Address | Notes |
|---|---|---|
| Semaphore v4 | 0x8A1fd199516489B0Fb7153EB5f075cDAC83c693D | Pre-deployed by PSE team |
| CourseRegistry | 0x8895d0401384Dffd60E53df362D3f422e2A0bF23 | Deployed this hackathon |
| CredentialIssuer | 0x9499153dDf0bD0c8A6F173d0bD4cF0780183e85D | Deployed this hackathon |
| CredentialVerifier | 0xAAe96283690450E6a869e2a44aAb4a04Cf453605 | Deployed this hackathon |
A student should never have to choose between proving their qualifications and protecting their identity. SecureHire makes both possible simultaneously — on-chain, without trust.