ex_did
ex_did is a typed DID resolution library for Elixir.
Quick links: Hex package | Hex docs | Changelog | Fixture policy | CI
The library exposes a method-agnostic API for DID parsing, DID resolution, DID representation resolution, DID URL dereferencing, and a small number of method-specific helper functions that remove common string-building mistakes.
ex_did is intentionally DID-only. VC, VP, JWT, JWS, Data Integrity, and
other SSI proof features belong in sibling libraries built on top of this DID
foundation.
Strict mode is canonical per method:
did:keystrict is Multikey-first for every supported multicodecdid:jwkstrict is JWK-native for every supported JWK familyvalidation: :compatexists for legacy/interoperability quirks, not as a second equal output family
Status
Current support:
- typed DID / DID URL parsing
- typed result structs for resolve, representation, and dereference operations
-
method registry with first-class
did:web,did:key, anddid:jwk -
strict validation by default with opt-in
validation: :compat -
deterministic local resolution for
did:keyanddid:jwk did:webURL mapping, fetching, validation, and dereferencing- verification method extraction across current and legacy shapes
-
maintainer-only parity corpora for both JavaScript resolvers and
ssi-dids
Not implemented yet:
- additional DID methods
-
more advanced HTTP caching policy for
did:web - broader DID document normalization rules beyond the current strict/compat set
-
broader
ssidisagreement handling beyond the currently documented fixtures
Installation
Add ex_did to your dependencies:
def deps do
[
{:ex_did, "~> 0.1.1"},
{:jose, "~> 1.11"}
]
endUsage
Normal ex_did usage does not require JavaScript, Rust, pnpm, Cargo, or
network access. Those are maintainer-only dependencies for rebuilding upstream
parity fixtures.
Parse a DID or DID URL:
{:ok, did_url} = ExDid.parse("did:web:example.com#key-1")
did_url.method
# => "web"Resolve a DID:
result = ExDid.resolve("did:key:z6MkeXCES4onVW4up9Qgz1KRnZsKmGufcaZxF6Zpv2w5QwUK")
document = result.did_document
verification_method = hd(document["verificationMethod"])Resolve a representation:
result = ExDid.resolve_representation("did:web:example.com", fetch_json: fn _url ->
{:ok, %{"@context" => ["https://www.w3.org/ns/did/v1"], "id" => "did:web:example.com"}}
end)
Jason.decode!(result.content_stream)Derefence a DID URL:
result = ExDid.dereference("did:web:example.com#key-1", fetch_json: fn _url ->
{:ok,
%{
"id" => "did:web:example.com",
"verificationMethod" => [
%{"id" => "did:web:example.com#key-1", "controller" => "did:web:example.com"}
]
}}
end)
result.content_stream["id"]Use compatibility mode only when needed for known ecosystem quirks:
result =
ExDid.resolve("did:jwk:...", validation: :compat)Override the registry or representation per call:
registry = %{"web" => ExDid.Method.Web}
result =
ExDid.resolve_representation("did:web:example.com",
method_registry: registry,
accept: "application/did+ld+json",
fetch_json: fn _url ->
{:ok, %{"@context" => ["https://www.w3.org/ns/did/v1"], "id" => "did:web:example.com"}}
end
)
Map a did:web DID to its document URL:
{:ok, url} = ExDid.resolve_url("did:web:example.com:user:alice")
# => "https://example.com/user/alice/did.json"
Build a canonical did:web value or canonical local verification method id:
{:ok, did} = ExDid.did_web("greenfield.gov")
{:ok, verification_method} =
ExDid.verification_method_id("did:key:z6MkeXCES4onVW4up9Qgz1KRnZsKmGufcaZxF6Zpv2w5QwUK")Supported Method Matrix
did:web
- HTTPS document resolution
-
DID document dereferencing for fragments and explicit
serviceparameter path/query handling
did:key
- supported multicodec prefixes: Ed25519, X25519, secp256k1, P-256, P-384, P-521
- fixture-covered examples: Ed25519, X25519, secp256k1, P-256, P-384
-
strict profile:
Multikey/publicKeyMultibase - compat profile: legacy JS shapes for Ed25519/X25519 where fixture-backed
did:jwk
- supported public key shapes: OKP, EC, RSA
- fixture-covered examples: OKP, EC P-256, RSA
-
strict profile: JWK-native
publicKeyJwk - compat profile: same output family, with leniency such as private-material stripping
Validation Modes
ex_did defaults to validation: :strict.
validation: :compat is opt-in and intentionally narrow. It currently only
relaxes behaviors that are explicitly implemented and covered by fixtures and
tests, such as normalizing a single service object into a list, preserving
legacy JS did:key shapes where documented, and stripping private material
from did:jwk inputs before building the public DID document.
Strict mode is the production default. Compat mode is an interoperability tool, not a second equal runtime profile.
did:web Transport Rules
did:web resolution accepts the following response media types:
-
strict:
application/did+json,application/did+ld+json -
compat: strict types plus
application/json
If the response media type falls outside those rules, resolution returns
invalidDidDocument.
did:web Rules
ex_did follows the standard did:web mapping:
did:web:example.com->https://example.com/.well-known/did.jsondid:web:example.com:user:alice->https://example.com/user/alice/did.jsondid:web:localhost%3A8443->https://localhost:8443/.well-known/did.json
URI-encoded DID path components are decoded before building the HTTPS URL.
Testing And Parity
The library is tested with:
-
vendored fixture documents under
test/fixtures/ -
JavaScript resolver parity corpora under
test/fixtures/upstream/ ssi-didsparity corpora undertest/fixtures/upstream/ssi/- fixture provenance manifests for both local deterministic fixtures and upstream-recorded fixtures
- property tests for DID parsing and deterministic local DID generation
-
strict / compat coverage for
did:web,did:key, anddid:jwk -
dereferencing coverage for
did:web,did:key, anddid:jwk -
injected fetch functions so
did:webresolution remains deterministic -
downstream validation against current
apps/delegateDID usage
Refresh upstream parity fixtures with the maintainer-only recorder:
cd libs/ex_did/scripts/upstream_parity
pnpm install
pnpm run record:released
pnpm run record:main
Refresh ssi parity fixtures with the maintainer-only Rust recorder:
cd libs/ex_did/scripts/ssi_parity
cargo run -- released
cargo run -- mainThe committed fixtures are the contract. End users and CI should not need to run either recorder.
Fixture Policy
The fixture policy is documented in FIXTURE_POLICY.md.
upstream/releasedis contractual and should back CIupstream/mainis advisory drift detectionupstream/ssi/releasedis contractual for overlapping DID-onlyssibehaviorupstream/ssi/mainis advisoryssidrift detection- scratch captures and debug output should not be committed
- compat behavior must be backed by committed upstream fixture evidence
When the JavaScript and ssi ecosystems disagree, ex_did documents the
disagreement in tests and keeps strict mode aligned to the library’s
method-specific canonical behavior rather than silently switching output
shapes. In practice, that means did:key strict follows the library’s
Multikey-first contract while did:jwk strict remains JWK-native even though
ssi may render that method differently.
Open Source Notes
- License: MIT
-
Changelog:
CHANGELOG.md -
Fixture policy:
FIXTURE_POLICY.md -
The current stable public facade is
ExDid; method modules are implementation details even though they are user-overridable through the method registry. - Canonical package repository: github.com/bawolf/ex_did
Maintainer Workflow
ex_did currently lives in the delegate monorepo and is mirrored into the
standalone ex_did repository for publishing and external consumption.
The intended workflow is:
-
make library changes in
libs/ex_did -
run
mix test -
sync the package into a clean checkout of
github.com/bawolf/ex_did - review and push from the standalone repo
A helper script for the sync step lives at scripts/sync_standalone_repo.sh.
The standalone repository also carries GitHub Actions workflows for:
- CI on push and pull request
-
manual Hex publishing through
workflow_dispatch
The publish workflow expects a HEX_API_KEY repository secret in the standalone
ex_did repository.