Building Decentralized Identity (DIDs) with Midnight Network
Bounty: midnightntwrk/contributor-hub #228
The promise of self-sovereign identity (SSI) is simple: you control your credentials, you choose what to reveal, and nobody gets to compile a dossier on you without your consent. The reality has been harder to deliver—until privacy-native blockchains like Midnight came along.
Midnight's zero-knowledge proof engine lets you prove facts about yourself without revealing the underlying data. You can prove you're over 18 without handing over your passport. You can prove you passed KYC without exposing your social security number. You can prove you're a member of a group without revealing which group or who else is in it.
In this tutorial you'll build a working Decentralized Identity system on Midnight:
- The SSI Mental Model — and Why Existing Approaches Fall Short
- Midnight's ZK Architecture for Identity
- Core Compact Contracts for DIDs
- TypeScript Witness Patterns
- KYC Without Revealing PII
- Age Verification: Proving You're Over 18
- Composing Credentials
- Production Considerations
- Summary
1. The SSI Mental Model
Self-sovereign identity has three actors:
| Actor | Role | Example |
|---|---|---|
| Issuer | Generates and signs a credential | A government, bank, or employer |
| Holder | Receives, stores, and presents credentials | You |
| Verifier | Checks that a credential claim is valid | A website, dApp, or employer |
The classic problem: in most SSI systems, the verifier receives a signed credential from the issuer. Even if the verifier only asks for "age ≥ 18", you have to hand over your full birthday and name so they can check the issuer's signature.
ZK-based SSI solves this. The holder generates a zero-knowledge proof that says: "I hold a credential signed by Issuer X, and that credential contains an attribute satisfying predicate P, but I will not reveal the attribute itself."
Midnight makes this the default mode of operation.
2. Midnight's ZK Architecture for Identity
Midnight contracts run in a shielded environment where:
- Ledger state is encrypted on-chain; only authorised key holders can decrypt it
- Witnesses are private inputs to ZK circuits — they never leave the holder's machine
- Proofs are succinct (~1-2 KB) and verifiable by anyone, including on-chain circuits
For identity use cases, the pattern is:
[Issuer] --(signed credential)--> [Holder's local wallet]
|
[TypeScript witness builder]
|
[ZK proof generation (Compact circuit)]
|
[Proof submitted on-chain]
|
[Verifier contract checks proof]
The credential never touches the chain. Only the proof does.
3. Core Compact Contracts for DIDs
Let's build from the ground up. We need:
- A DID Registry contract — maps decentralised identifiers to public keys
- A Credential Verifier contract — accepts ZK proofs of credential attributes
3.1 DID Registry
pragma language_version >= 0.14;
import CompactStandardLibrary;
// Map from DID (32-byte hash) to owner's public key
export ledger didRegistry: Map<Bytes<32>, Bytes<32>>;
// Create a new DID
export circuit registerDid(
did: Bytes<32>,
ownerPublicKey: Bytes<32>
): [] {
// Ensure DID not already registered
const exists = didRegistry.member(did);
assert !exists : "DID already registered";
didRegistry.insert_or_update(did, ownerPublicKey);
}
// Rotate the key associated with a DID
export circuit rotateKey(
did: Bytes<32>,
currentSig: Bytes<64>, // proof of ownership of current key
newPublicKey: Bytes<32>
): [] {
const currentKey = didRegistry.lookup(did);
assert verify_signature(currentKey, currentSig, newPublicKey) : "Invalid signature";
didRegistry.insert_or_update(did, newPublicKey);
}
// Resolve a DID to its current public key
export circuit resolveDid(
did: Bytes<32>
): Bytes<32> {
return didRegistry.lookup(did);
}
3.2 Credential Verifier Contract
This is where the ZK magic lives. The verifier circuit takes a proof that the holder's credential satisfies a predicate and returns a boolean — without seeing the credential itself.
pragma language_version >= 0.14;
import CompactStandardLibrary;
// Issuer's public key (set at deploy time)
export ledger issuerPublicKey: Bytes<32>;
// Nullifier set — prevents credential replay
export ledger usedNullifiers: Map<Bytes<32>, Boolean>;
// Verify that the caller holds a valid credential from the issuer
// where attribute >= threshold (e.g., age >= 18)
export circuit verifyAttributeThreshold(
// Public inputs
did: Bytes<32>,
threshold: UInt64,
nullifier: Bytes<32>, // unique per (credential, verifier) pair
// Private witness (provided locally, never on-chain)
witness credentialValue: UInt64, // the actual attribute value
witness credentialSignature: Bytes<64>, // issuer's signature
witness credentialCommitment: Bytes<32> // commitment binding value to DID
): Boolean {
// 1. Ensure this credential hasn't been used here before
const alreadyUsed = usedNullifiers.lookup_with_default(nullifier, false);
assert !alreadyUsed : "Nullifier already used";
// 2. Verify issuer signed this (credentialValue, did) pair
// In practice: signature over hash(did || credentialValue || nonce)
assert verify_signature(
issuerPublicKey,
credentialSignature,
credentialCommitment
) : "Invalid issuer signature";
// 3. The ZK proof proves: credentialValue >= threshold
// without revealing credentialValue
assert credentialValue >= threshold : "Attribute below threshold";
// 4. Record the nullifier to prevent replay
usedNullifiers.insert_or_update(nullifier, true);
return true;
}
Key insight: The
witnesskeyword marks inputs as private. The ZK proof system proves the assertions hold for some value of the witness — without including the witness in the proof or the transaction.
4. TypeScript Witness Patterns
The witness pattern is how you pass private data to a Compact circuit from TypeScript. The SDK's WitnessProvider interface lets you inject witnesses at proof-generation time.
4.1 Implementing a WitnessProvider
import {
WitnessProvider,
PrivateState,
ContractAddress,
} from '@midnight-ntwrk/midnight-js-types';
import { MidnightProviders } from '@midnight-ntwrk/midnight-js-contracts';
// Credential stored in the holder's local wallet
interface IdentityCredential {
did: Uint8Array; // 32-byte DID
attributeValue: bigint; // e.g., age as unix timestamp of birthday
issuerSignature: Uint8Array; // 64-byte signature
commitment: Uint8Array; // 32-byte commitment
}
// The private state the circuit needs
interface IdentityPrivateState extends PrivateState {
credential: IdentityCredential;
}
// WitnessProvider implementation
class IdentityWitnessProvider implements WitnessProvider<IdentityPrivateState> {
constructor(private readonly credential: IdentityCredential) {}
// Called by the SDK when building a ZK proof
async provideWitness(
circuitName: string,
privateState: IdentityPrivateState,
): Promise<IdentityPrivateState> {
// Inject the credential into the private state
return {
...privateState,
credential: this.credential,
};
}
}
4.2 Using the WitnessProvider in a Contract Call
import {
createMidnightClient,
ContractAddress,
} from '@midnight-ntwrk/midnight-js-contracts';
import { credentialVerifierInstance } from './generated/credentialVerifier';
async function proveAgeOver18(
providers: MidnightProviders,
contractAddress: ContractAddress,
credential: IdentityCredential,
nullifier: Uint8Array,
) {
const client = createMidnightClient(providers);
// Attach our witness provider so the SDK can inject private data
const witnessProvider = new IdentityWitnessProvider(credential);
const contract = await client.getContract(
credentialVerifierInstance,
contractAddress,
{ witnessProvider }, // ← key: inject witnesses here
);
const AGE_18_THRESHOLD = 18n;
// The SDK will:
// 1. Call witnessProvider.provideWitness() to get private inputs
// 2. Run the Compact circuit locally to generate a ZK proof
// 3. Submit only the proof (not the credential) on-chain
const tx = await contract.callTx.verifyAttributeThreshold(
credential.did, // public input: DID
AGE_18_THRESHOLD, // public input: threshold
nullifier, // public input: nullifier
// Private witnesses are injected automatically via witnessProvider
);
await tx.submit();
console.log('Age proof submitted. Tx:', tx.hash);
}
4.3 Managing Private State Locally
Witnesses are private and never leave the device. The SDK provides utilities for encrypted local storage:
import { PrivateStateProvider } from '@midnight-ntwrk/midnight-js-contracts';
import { encryptToLocalStore, decryptFromLocalStore } from './localCrypto';
class SecureCredentialStore implements PrivateStateProvider<IdentityPrivateState> {
private readonly storageKey: string;
constructor(private readonly userSecretKey: Uint8Array) {
this.storageKey = 'identity_credentials';
}
async getPrivateState(
contractAddress: ContractAddress,
): Promise<IdentityPrivateState | undefined> {
const encrypted = localStorage.getItem(`${this.storageKey}_${contractAddress}`);
if (!encrypted) return undefined;
const decrypted = decryptFromLocalStore(this.userSecretKey, encrypted);
return JSON.parse(decrypted);
}
async setPrivateState(
contractAddress: ContractAddress,
state: IdentityPrivateState,
): Promise<void> {
const serialised = JSON.stringify(state);
const encrypted = encryptToLocalStore(this.userSecretKey, serialised);
localStorage.setItem(`${this.storageKey}_${contractAddress}`, encrypted);
}
}
5. KYC Without Revealing PII
Know Your Customer (KYC) is one of the highest-value use cases for ZK identity. Regulators require verifying who users are; users don't want to hand their passport to every DeFi protocol.
5.1 The Flow
[KYC Provider (Issuer)]
│
│ Issues signed credential:
│ { did, jurisdiction, kycLevel, expiryDate, issuerSig }
│
▼
[User's Wallet (Holder)]
│
│ Generates ZK proof:
│ "I hold a Level-2 KYC credential from an approved issuer,
│ valid as of today, for this DID — without revealing any PII"
│
▼
[DeFi Protocol (Verifier)]
│
│ Verifies proof on-chain.
│ Records nullifier to prevent replay.
│ Grants access.
▼
[Access granted — no PII stored on-chain]
5.2 KYC Credential Compact Contract
pragma language_version >= 0.14;
import CompactStandardLibrary;
// Approved KYC issuers
export ledger approvedIssuers: Map<Bytes<32>, Boolean>;
// Used nullifiers per verifier session
export ledger kycNullifiers: Map<Bytes<32>, Boolean>;
// Add/remove approved issuers (governance-controlled)
export circuit addIssuer(adminSig: Bytes<64>, issuerKey: Bytes<32>): [] {
// Governance check omitted for brevity
approvedIssuers.insert_or_update(issuerKey, true);
}
// Verify KYC without PII
export circuit verifyKyc(
did: Bytes<32>,
requiredLevel: UInt64, // e.g., 1 = basic, 2 = full KYC
nullifier: Bytes<32>,
witness kycLevel: UInt64,
witness issuerKey: Bytes<32>,
witness issuerSignature: Bytes<64>,
witness credentialExpiry: UInt64,
witness currentTimestamp: UInt64,
witness commitment: Bytes<32>
): Boolean {
// 1. Issuer must be approved
const issuerApproved = approvedIssuers.lookup_with_default(issuerKey, false);
assert issuerApproved : "Issuer not approved";
// 2. Credential must not be expired
assert currentTimestamp <= credentialExpiry : "Credential expired";
// 3. KYC level must meet requirement
assert kycLevel >= requiredLevel : "Insufficient KYC level";
// 4. Issuer signature must be valid
assert verify_signature(issuerKey, issuerSignature, commitment) : "Invalid signature";
// 5. Nullifier prevents reuse
const used = kycNullifiers.lookup_with_default(nullifier, false);
assert !used : "Already used";
kycNullifiers.insert_or_update(nullifier, true);
return true;
}
5.3 TypeScript: Submitting a KYC Proof
interface KycCredential {
did: Uint8Array;
kycLevel: bigint;
issuerKey: Uint8Array;
issuerSignature: Uint8Array;
credentialExpiry: bigint;
commitment: Uint8Array;
}
async function submitKycProof(
providers: MidnightProviders,
contractAddress: ContractAddress,
credential: KycCredential,
requiredLevel: bigint,
) {
// Derive a unique nullifier: hash(credential.commitment || verifierContract)
const nullifier = await deriveNullifier(
credential.commitment,
contractAddress,
);
const witnessProvider = {
async provideWitness(circuitName: string, state: any) {
return {
...state,
kycLevel: credential.kycLevel,
issuerKey: credential.issuerKey,
issuerSignature: credential.issuerSignature,
credentialExpiry: credential.credentialExpiry,
currentTimestamp: BigInt(Math.floor(Date.now() / 1000)),
commitment: credential.commitment,
};
},
};
const client = createMidnightClient(providers);
const contract = await client.getContract(
kycVerifierInstance,
contractAddress,
{ witnessProvider },
);
const tx = await contract.callTx.verifyKyc(
credential.did,
requiredLevel,
nullifier,
);
await tx.submit();
return tx.hash;
}
6. Age Verification: Proving You're Over 18
Age verification is simpler than full KYC but follows the same pattern. The key difference: the predicate is birthday <= (now - 18 years) expressed as unix timestamps.
// Convert a date of birth to unix timestamp
function dobToTimestamp(year: number, month: number, day: number): bigint {
return BigInt(new Date(year, month - 1, day).getTime() / 1000);
}
// Derive the threshold: unix timestamp 18 years ago
function ageThreshold18(): bigint {
const eighteenYearsAgo = new Date();
eighteenYearsAgo.setFullYear(eighteenYearsAgo.getFullYear() - 18);
return BigInt(Math.floor(eighteenYearsAgo.getTime() / 1000));
}
async function proveAgeVerification(
providers: MidnightProviders,
contractAddress: ContractAddress,
credential: IdentityCredential, // attributeValue = unix timestamp of birthday
) {
const threshold = ageThreshold18();
// credentialValue (birthday) must be <= threshold (18 years ago)
// i.e., proving birthday is far enough in the past
const nullifier = await deriveNullifier(credential.commitment, contractAddress);
const witnessProvider = {
async provideWitness(_: string, state: any) {
return {
...state,
credentialValue: credential.attributeValue, // birthday timestamp (private)
credentialSignature: credential.issuerSignature,
credentialCommitment: credential.commitment,
};
},
};
const client = createMidnightClient(providers);
const contract = await client.getContract(
credentialVerifierInstance,
contractAddress,
{ witnessProvider },
);
// The circuit proves birthday <= threshold without revealing birthday
const tx = await contract.callTx.verifyAttributeThreshold(
credential.did,
threshold,
nullifier,
);
await tx.submit();
console.log('Age verification proof submitted:', tx.hash);
}
7. Composing Credentials
Real-world identity often requires combining multiple credentials: "I am over 18 AND I am a resident of the EU AND I have passed KYC." Midnight supports this via composed circuits.
pragma language_version >= 0.14;
import CompactStandardLibrary;
// Combined check: KYC level 2 + age >= 18 + jurisdiction = EU
export circuit verifyEuAdultKyc(
did: Bytes<32>,
ageThreshold: UInt64,
kycRequiredLevel: UInt64,
nullifier: Bytes<32>,
witness age: UInt64,
witness kycLevel: UInt64,
witness jurisdictionCode: UInt64, // e.g., 1 = EU
witness issuerSig: Bytes<64>,
witness commitment: Bytes<32>
): Boolean {
// Age check
assert age >= ageThreshold : "Age requirement not met";
// KYC level check
assert kycLevel >= kycRequiredLevel : "KYC level insufficient";
// Jurisdiction check (EU = 1)
assert jurisdictionCode == 1 as UInt64 : "Not EU jurisdiction";
// Issuer signature
assert verify_signature(issuerPublicKey, issuerSig, commitment) : "Invalid sig";
// Nullifier
const used = nullifiers.lookup_with_default(nullifier, false);
assert !used : "Nullifier reused";
nullifiers.insert_or_update(nullifier, true);
return true;
}
From TypeScript, the call is identical — you just provide more witnesses. The ZK proof encapsulates all three assertions in a single compact proof.
8. Production Considerations
8.1 Credential Storage
Never store raw credentials on-chain. Keep them in:
-
Browser: encrypted
localStorage(AES-256-GCM, key derived from user passphrase) - Mobile: device secure enclave / keychain
- Desktop: OS keychain integration via the Midnight wallet
8.2 Nullifier Design
A nullifier must be:
- Unique per credential-verifier pair (prevents cross-context tracking)
- Deterministically derivable by the holder (so it doesn't need to be stored)
- Not linkable to the credential (prevents de-anonymisation)
Standard approach: nullifier = hash(credentialSecret || verifierContractAddress || sessionNonce)
8.3 Credential Expiry
Always include an expiry timestamp in the credential and assert it in the circuit. Without this, a compromised credential remains valid forever.
8.4 Issuer Key Rotation
Build issuer key rotation into your DID registry from day one. A compromised issuer key without rotation capability invalidates every credential it ever signed, with no recourse.
// Always version your issuer keys
export ledger issuerKeys: Map<UInt64, Bytes<32>>; // version → key
export ledger currentIssuerVersion: UInt64;
// Accept credentials signed by any non-revoked issuer version
export circuit verifyWithKeyVersion(
// ...
witness keyVersion: UInt64,
// ...
): Boolean {
const issuerKey = issuerKeys.lookup(keyVersion);
// ... rest of verification
}
8.5 Selective Disclosure
For maximum privacy, structure credentials so attributes can be disclosed selectively. Use commitment schemes: the issuer commits to a vector of attributes with a single signature, and the holder proves individual attributes using Merkle paths over the commitment.
9. Summary
Midnight's zero-knowledge architecture makes it uniquely suited for decentralised identity. The key design patterns:
| Pattern | What It Solves |
|---|---|
| Witness injection | Keeps sensitive data off-chain and out of proofs |
| Nullifiers | Prevents credential replay without linking presentations |
| Merkle credentials | Enables selective disclosure of individual attributes |
| Threshold predicates | Proves range/comparison facts without revealing values |
| DID registry | Gives credentials a stable, rotatable on-chain anchor |
The TypeScript SDK makes this approachable: implement WitnessProvider, call contract.callTx.yourCircuit(), and let the SDK handle ZK proof generation. The private inputs never leave the holder's machine.
This is what self-sovereign identity looks like when privacy is a first-class primitive — not an afterthought.
Want to go deeper? See the companion tutorial on Maps and Merkle Trees in Compact for the underlying data structures powering these identity patterns. Questions? Open an issue on the Midnight contributor hub.
Top comments (0)