Identity · did:midnight + Identus

Credentials prove who. Midnight proves nothing changed.

There are now two credible ways to do identity on Midnight. The native track — the did:midnight method plus Compact-first verifiable credentials, both published by Midnight itself. And the Identus track — a mature Hyperledger agent issuing W3C credentials, with Midnight underneath as a private anchor. This page covers both, and says plainly which one to reach for. Everything below is from published specs and working builds, not the marketing page.

Two tracks, one problem

Pick the one your weekend can carry.

Native · did:midnight

ZK-native, nothing extra to host

A Compact contract is the DID registry; claim privacy comes from commitments inside the circuit rather than from a wallet protocol. No Scala agent, no Postgres, no PRISM node — just the node, indexer and proof server you already run. Cost: the method spec is draft v0.6.0 and the credential packages are a mix of RC and prototype tiers, so pin exact versions and expect surface changes.

Identus

Mature agent, you own the bridge

DIDComm, OID4VC and W3C VC/VP work out of the box with SDKs for TypeScript, Kotlin and Swift. Cost: you host a Cloud Agent plus Postgres plus a PRISM node (4 GB, four Flyway migrations on first boot), and anchoring to Midnight is a bridge you write yourself — which is exactly what IPS Compass below does.

Choose native when

The claim itself must stay hidden

Age gates, licence tiers, entitlement checks, allowlists — anything where the verifier needs a predicate to hold and must not learn the underlying value. Selective disclosure is a first-class part of the model, not a bolt-on.

Choose Identus when

A third party must accept the credential

Existing wallets, existing verifiers, an OID4VC integration, or a regulator that wants a recognisable W3C VC. Then anchor a salted commitment on Midnight so the document is provably unchanged without being disclosed.

They are not rivals. The DID method spec is co-authored by IAMX, the Midnight Foundation and Hyperledger Identus, and the credential repo ships a credential-did-midnight adapter — the intended end state is Identus-shaped tooling resolving did:midnight identifiers.

did:midnight · spec draft v0.6.0

An address is an identity.

A ledger-backed Midnight DID is a contract address. That single decision gives you controller authorisation, uniqueness and deactivation for free — the ledger already enforces them. Note the network segment: undeployed is in the grammar, so the DIDs you mint on a local stack are spec-valid, not toys.

DID syntax (ABNF)
midnight-did  = ledger-did / offchain-did
ledger-did    = "did:midnight:" ledger-network ":" 64HEXDIGL
ledger-network= "undeployed" | "devnet" | "testnet"
              | "mainnet" | "preview" | "preprod"
offchain-did  = "did:midnight:offchain:" 64HEXDIGL [ ":" offchain-state ]

# ledger id  = the 32-byte contract address, 64 lowercase hex chars
# offchain id = 64-hex hash of the encoded offchain DID state
# every id MUST match /^[0-9a-f]{64}$/ — canonicalise BEFORE resolving

did:midnight:undeployed:c569622e7f33d2d020ba1cae242e6077268941327846d62d8cbf0cc923ae41f6
Canonicalise first

Lowercase hex, always

Identifiers MUST match /^[0-9a-f]{64}$/. Contract addresses arrive from tooling in mixed case often enough that this is the number-one reason a freshly created DID fails to resolve. Lowercase at the boundary — on the way in and before you construct a DID — not at the comparison site.

Offchain DIDs

Long form now, short form later

did:midnight:offchain:<hash>[:<state>] is did:peer:4 portability adapted to Midnight crypto. The long form carries the encoded DID state so a verifier can resolve it with no registry lookup at all; once the state is known, the hash-only short form is enough. Perfect for a hackathon demo with no published registry.

Key model

Two key maps, one document

Opaque W3C JWKs are stored as canonical strings; BLS12381G1/G2 as compressed publicKeyJwk point strings; native SchnorrJubjub as JubjubPoint values. The resolver merges both maps into the DID Document, and on-ledger SchnorrJubjub verification reads the key by verification-method id.

Layer boundary

What Compact is allowed to know

The contract enforces controller auth, active/deactivated state, identifier uniqueness, relation and service membership, the key-profile enum, and the SchnorrJubjub circuit. DID URL subject binding and document assembly live in TypeScript because Compact cannot express them safely. Resolver service, DID manager UI and secret custody live in a separate repo entirely — do not grow them into your contract.

Compact-first credentials

Public claims, private commitments.

The whole credential model hangs off one generic envelope. Keep it generic: the moment you flatten it into a bespoke struct you lose the ability to swap holder binding or status backends, which is the entire point of the package split.

The envelope
// the generic envelope — do not flatten it
VC<TPublicClaims, TClaimCommitments, THolderBinding, TStatusBinding>

claims           // PUBLIC. allowlist it. it is on the wire and on the ledger.
claimCommitments // PRIVATE surface. commitments only, never plaintext.
holderBinding    // who may present. unlinkable + verifier-request-bound.
statusBinding    // replaceable port -> credentials-status-registry
credential-model
Protocol-neutral family authoring — descriptors, codecs, manifests. Start every new credential type here.
credential-compact
Compact VC/VP semantics, generated contract bindings, holder-binding exports.
credential-proofs
Family-neutral proof and artifact contracts — keep proving concerns out of your family package.
credential-status
Status bindings and policies behind replaceable ports; credentials-status-registry is the current prototype revocation trust model.
credential-did-midnight
Off-chain did:midnight resolution plus the holder-binding adapter — the seam between the two tracks.
credentials-hello-family
Smallest compileable starter family. Clone this, not the passport.

Maturity is deliberately uneven: credential-model is a supported RC2 npm foundation, while the passport family is described as the first graduation candidate and explicitly not production-ready. Read the package tier inventory before you build a product on any one of them.

Selective disclosure · the part people get wrong

A type cast is not a privacy migration.

Moving a field from claims to claimCommitments in a type definition changes nothing on the wire. The verifier/circuit trust boundary either moved or it did not — and the only way to know is to assert on the serialised payload.

Opening discipline
// an "opening" is the (value, randomness) pair that reveals one claim
opening = random(32 bytes, CSPRNG)          // private, high entropy
commit  = persistentCommit<TClaim>(value, domainSep(field) || opening)

// NEVER derive an opening from any of these:
//   the holder's DID, a fixture, a student/record id, a routing key,
//   a counter, a timestamp, or anything a verifier already sees.
// Domain-separate per FIELD, not per credential — reused separators
// let a verifier link two disclosures of different fields.
// Openings MUST survive process restart (durable holder store) or the
// flow is not private-by-default — say so instead of implying it is.
Test the bytes

Serialised payloads, exports, proof-server DTOs

For every presentation, assert four things: hidden plaintext is absent, public claims are on an allowlist, the expected commitments are present, and only the requested (value, opening) pairs disclose. Then assert the negative cases — altered openings and known/default openings must fail verification or recovery.

Unlinkable subject

Bind the reference to the request

The subject reference in a presentation must be verifier- and request-bound, so two presentations of the same credential to two verifiers cannot be correlated. Deriving it from a public DID or a stable record id silently destroys that property while every test still passes.

Durability

Openings that die on restart are a bug

Holder-private opening and correlation material must survive every supported restart. If yours is process-local or transport-linkable, document that limitation on the page — do not present the flow as durable and private by default.

Toolchain

Pinned Nix shell, pnpm workspace

The repo enters via nix develop, which runs just bootstrap; then just check for the light non-Docker gate and just targets to list lanes. Default branch is develop, commits are DCO-signed. Set MVVC_SKIP_BOOTSTRAP=1 to enter without reinstalling. Don't hand-copy dist/ — use pnpm run artifacts:pack.

The pieces

One agent, three services, two SDK edges.

Cloud Agent

The REST service you actually call

A Scala/JVM service exposing DID registrar, connections, issuance and presentation endpoints. It needs a Postgres and a PRISM node beside it; first boot migrates four separate databases, which is why it looks slow and why it needs 4 GB.

PRISM node

DID method backing

Publishes did:prism documents. Without Cardano ledger backing it resolves only inside your own stack — fine for a demo, but say so: a third party cannot resolve those DIDs.

Edge SDKs

TypeScript / Kotlin / Swift

Wallet-side libraries for holding credentials and answering presentation requests. In a Lovable app the browser talks to your own server functions, and only the server holds the agent's admin key.

DIDComm (optional)

Skip it with connectionless issuance

A Mediator lets remote wallets exchange DIDComm messages. If you only need a credential over a digest, connectionless issuance avoids the whole invitation dance — one REST call, an invitation URL, no established connection.

ZK binding (optional)

Prove a predicate, not the credential

A credential carrying a date of birth can back a browser-side age proof: hash the signed JWT into two 128-bit field limbs as a binding, prove over18 with Noir + UltraHonk in the page, and store only the commitment and public inputs. The JWT never leaves the browser, and a credential with no birth claim must be shown as unprovable rather than silently offered.

Why pair them

Digest → credential → commitment.

The shape below is the one verified in IPS Compass (clinical summaries), and it transfers unchanged to a dance licence, a ticket entitlement or an agent mandate. The document never leaves your database; the digest is the only thing that travels, and the ledger only ever sees a salted commitment.

  1. 01

    Serialise and digest

    Canonicalise the document (stable key order, normalised whitespace) and hash it with SHA-256. Non-deterministic serialisation is the number-one reason a verifier recomputes a different digest later.

  2. 02

    Issue a credential over the digest

    Claims carry the digest, the credential type and at most a derived boolean (e.g. over18). Never a name, a date of birth, or any body content — a credential is not a place to park data you refused to put on a ledger.

  3. 03

    Anchor a salted commitment

    commitment = H(domain ‖ digest ‖ salt), inserted into an append-only Compact Set. Persist the salt with the anchor — an anchor whose salt is missing is unverifiable and must fail closed, not read as confirmed.

  4. 04

    Verify each link independently

    Structural validation, digest match, a real credential (a pending offer with no JWT is not one), and a membership read against the ledger. Report each pass separately so a partial failure names the broken link.

Append-only commitment registry
commitment = persistentHash("ips:anchor:v1" || sha256(bundle) || salt)

// contracts/IpsAnchorRegistry.compact — append-only, duplicates rejected
export ledger commitments: Set<Bytes<32>>;

export circuit anchor(commitment: Bytes<32>): [] {
  assert(!commitments.member(disclose(commitment)), "already anchored");
  commitments.insert(disclose(commitment));
}

Insert-only is not a style choice — overwriting an existing key in a public ledger map makes the dust fee balancer panic on the next call. See Known issues.

Say what you actually check

Three claims almost every demo overstates.

Not verification

Decoding a JWT

Reading a credential payload is not JWS verification: no signature check, no issuer DID resolution, no status-list lookup. Label it “issuer signature: not verified” until you do all three.

Not verification

A transaction hash

A tx hash proves you submitted something. On-chain verification is a read: load public state from the indexer and ask whether commitments.member(commitment) holds.

Not a trust chain

Simulated mode

An in-app mock issuer is invaluable for UI work (alg: none, stub signature, always healthy) — and proves nothing. Mark simulated credentials as such in the UI, every time.

Three ways to run the agent

Simulated → Docker → Fly Machines.

Simulated
In-app mock backed by your own tables. No external service, always healthy. Use it to build every screen before you wait on a JVM boot.
Docker (local)
docker compose stack reached at http://localhost:8085/cloud-agent. Localhost only — external DIDComm peers need a tunnel.
Fly.io Machines
Postgres + prism-node + cloud-agent machines, HTTPS at the app root. Strip /cloud-agent from the stored base URL for this mode — there is no APISIX gateway in front of a direct Fly deploy.

Pin every image

  • docker.io/identus/identus-cloud-agent:1.40.0
  • docker.io/identus/prism-node:2.5.0
  • docker.io/postgres:13-alpine

Docker Hub, not GHCR (not anonymously pullable). Never :latest — a re-pushed tag shipped incompatible proving keys mid-demo. Pin digests if you can and record the resolved digest with the deployment.

Four databases, four roles

Create pollux, connect, agent and node as separate databases to avoid migration collisions — and one <db>-application-user LOGIN role inside each. The agent does not connect as the superuser.

Postgres init SQL
-- Identus 1.40's FIRST Flyway statement is
--   ALTER DEFAULT PRIVILEGES ... TO "<db>-application-user"
-- so that LOGIN role must exist per database, or the agent exits 1.
CREATE DATABASE pollux;  CREATE DATABASE connect;
CREATE DATABASE agent;   CREATE DATABASE node;

\connect pollux
CREATE ROLE "pollux-application-user" WITH LOGIN PASSWORD '<pw>';
GRANT USAGE, CREATE ON SCHEMA public TO "pollux-application-user";
ALTER DEFAULT PRIVILEGES IN SCHEMA public
  GRANT ALL PRIVILEGES ON TABLES TO "pollux-application-user";
-- repeat the \connect + CREATE ROLE + GRANT block for connect, agent, node
Fly machine config that actually boots
# Fly private DNS (<group>.process.<app>.internal) resolves ONLY when the
# machine declares its process group. 6PN is IPv6-only.
config.metadata.fly_process_group = "identus-postgres" | "prism-node" | "cloud-agent"

JAVA_TOOL_OPTIONS="-Djava.net.preferIPv6Addresses=true \
  -Djava.net.preferIPv4Stack=false -XX:MaxRAMPercentage=70"

# Midnight side of the same app
node RPC bind:  [::]:9944
indexer:        APP__INFRA__API__ADDRESS: "::"

# checks: grace_period 300s · agent memory >= 4096 MB · poll timeout <= 60s
DIDCOMM_SERVICE_URL=https://<app>.fly.dev:8090   # publish internal port 8090
Build the diagnostics path first
# The Machines API has no per-container log endpoint, and a crash-looping
# JVM is drowned out by healthy sibling output. Tee it, then read it back.
sh -c 'identus-cloud-agent > /tmp/agent-boot.log 2>&1; c=$?; exit $c' &
tail -F /tmp/agent-boot.log

# read from outside:  POST /v1/apps/<app>/machines/<id>/exec
#   { "cmd": ["sh","-c","tail -n 400 /tmp/agent-boot.log"] }
Hard-won invariants

Non-negotiables.

  • ▸Only a PUBLISHED did:prism carrying an assertionMethod key can sign a credential offer. Filter the issuer picker by resolving each DID and show why the excluded ones are excluded.
  • ▸Connectionless issuance omits connectionId; passing one without an established connection returns a 400 that reads like an agent fault.
  • ▸DIDComm invitations must advertise a reachable host: publish internal port 8090 and set DIDCOMM_SERVICE_URL=https://<app>.fly.dev:8090. Repair the endpoint on existing apps instead of redeploying.
  • ▸grace_period 300s on the health check — first boot migrates four databases and a shorter window restarts the machine mid-migration.
  • ▸Agent memory 4 GB or more; anything less gets OOM-killed during that first migration.
  • ▸Cap a single readiness poll at 60s — the Machines API rejects longer timeouts with a 400.
  • ▸Every provisioning function must degrade gracefully when the Fly token is absent: return an "unconfigured" state the UI can render, never throw inside a loader.
  • ▸Treat 404 from a destroy/read as "already gone" and mark the record orphaned — Fly resources vanish outside your app.
  • ▸Scope unique indexes on stack tables by (user_id, kind), or provisioning an Identus stack silently overwrites the Midnight one.
  • ▸Provision / check / repair / repair-agent-DB / destroy are separate idempotent operations. Repairing a broken agent must never restart a healthy ledger.
  • ▸Secrets stay server-side: read the Fly token and admin key inside .handler() bodies, keep agent clients in *.server.ts, and let routes import only *.functions.ts.
  • ▸No env var or flag ships without an upstream source that says it is required. Two speculative fixes (a derived wallet seed, a duplicate POSTGRES_* group) cost a debugging cycle and hid the real error.
Failure modes we hit

Symptom, root cause, fix.

SymptomRoot causeFix
Agent: UnknownHostException resolving identus-postgres.process.<app>.internalFly private DNS keys off process-group metadata, not the machine nameSet config.metadata.fly_process_group on every machine; add a repair action that back-fills it on existing stacks
zio.FiberFailure: ERROR: role "pollux-application-user" does not existIdentus 1.40 connects as per-database application roles created by upstream's compose init scriptsInit SQL creates one <db>-application-user per database with GRANT USAGE, CREATE + ALTER DEFAULT PRIVILEGES, inside each DB via \connect hops
The role fix has no effect on an existing stackPostgres init scripts run only against an EMPTY data directoryDestroy and recreate just the Identus Postgres machine (a "Fix agent DB" action) — never a full-stack redeploy that also restarts a healthy ledger
Flyway migration fails with a syntax error near FORMATPostgres drifted off 13 — FORMAT is reserved from 14 onwardPin postgres:13-alpine and recreate the machine on a fresh volume
Midnight node crash-loops, exit code 1, seconds after startThe 0.22.x node image rejected hand-rolled dev-network CLI flagsmidnight-node driven by CFG_PRESET=dev (auto-authoring) instead of flags
Node and indexer running but unreachable from sibling machinesServices bound to IPv4 loopback; Fly's private network is IPv6-onlyBind node RPC to [::]:9944, set APP__INFRA__API__ADDRESS: "::", give the JVM preferIPv6Addresses
Chain state lost on every machine replacementThe node wrote to the container filesystemAttach a Fly volume (10 GB) mounted at the node's chain directory
Log tail returns failed_precondition: machine not runningA crash-looping machine is down for part of each cycle, so exec has no targetFile-based fallbacks and a short post-crash window — but the real fix is stopping the crash, not reading it faster
Health probes spin forever while the machine is already deadStep state derived from probe results aloneDerive state from machine state first, short-circuit downstream probes on a boot failure, and report restart counts + OOM kills
GHCR image pull unauthorizedGHCR is not anonymously pullableUse the Docker Hub tags with explicit versions; never :latest (a re-pushed proof-server:latest shipped incompatible proving keys mid-demo)
Agent wallet resource acquisition fails after any restart, deep in a ZIO traceDEFAULT_WALLET_SEED regenerated randomly per bootDerive the seed deterministically (hash of app name + a stored salt) so it survives restarts — and never invent env vars upstream does not document
Provisioning an Identus stack overwrites the Midnight one for the same userunique (user_id) on the stack/connection tableScope the unique index (user_id, kind) so records for different stacks coexist
A credential shows a green "verified" badge that no reviewer acceptsStatus read from your own database, or a JWT merely decoded rather than signature-checkedVerify against the source of truth in-request — contract membership for the anchor, a real signature check for the credential — and render simulated or unresolvable artefacts as not checked

The meta-lesson: porting a compose stack to Fly Machines is a translation job. Read the upstream compose and init scripts before writing a single machine spec — every failure above was already answered there.

What's the opportunity?

Where credentials plus private anchoring beat either alone.

Clinical and personal records

An International Patient Summary is authored, digested and attested, and only a salted commitment reaches the ledger. A verifier confirms the summary is the one that was issued and unchanged, without a byte of clinical data leaving the source.

Credential-gated agentic commerce

Gate an A2A / AP2 / x402 flow on a credential before the payment circuit runs. Watch the subjects: the human principal holds the credential, the AI agent holds the mandate — cross-comparing those two DIDs is the classic false “credential mismatch” rejection.

Eligibility and ticketing

Prove over-18, membership or residency from a derived boolean claim, then anchor the entitlement commitment so a door scan is a membership read rather than a database lookup against a list of names.

Licence and provenance for creative work

Issue a credential over a choreography, score or master file digest, anchor it privately, and settle usage on a mimic-token rail. The credential names the author; the ledger proves the file predates the dispute.

References

Read these.

Sources: the Identus Catalyst console and the Identus NHS console (agent provisioning, diagnostics and the ZK presentation layer) plus the IPS Compass build, which is the one that joins Identus credentials to a Midnight Compact anchor end to end. Dev-network only, unaudited, and not for real patient data.