DEV Community

Tosh
Tosh

Posted on

Building Decentralized Identity (DIDs) with Midnight Network

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:

  1. The SSI Mental Model — and Why Existing Approaches Fall Short
  2. Midnight's ZK Architecture for Identity
  3. Core Compact Contracts for DIDs
  4. TypeScript Witness Patterns
  5. KYC Without Revealing PII
  6. Age Verification: Proving You're Over 18
  7. Composing Credentials
  8. Production Considerations
  9. 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]
Enter fullscreen mode Exit fullscreen mode

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:

  1. A DID Registry contract — maps decentralised identifiers to public keys
  2. 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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Key insight: The witness keyword 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,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)