A pure-Dart library for the did:webvh (DID Web + Verifiable History) DID Method,
v1.0. It is a faithful port of the reference Java library
didwebvh-java — built to behave identically and
verified against the same shared interop vectors — written with a Dart-idiomatic mindset.
Create, resolve, update, migrate, deactivate, and publish a parallel did:web
document for did:webvh DIDs, with pluggable key management (local keys, KMS/HSM, external signing APIs).
| Package | Purpose |
|---|---|
didwebvh |
The library: model, crypto primitives (JCS, multihash, base58btc, multikey), SCID/entry-hash/proofs, create/update/resolve/validate, witness, did:web. |
didwebvh_signing_local |
LocalKeySigner — Ed25519 signing from a local JWK, implementing the async Signer. |
didwebvh_wizard |
Interactive CLI for guided DID management. |
- Full did:webvh v1.0 support: SCID generation, authorization keys,
eddsa-jcs-2022Data Integrity proofs - Resolve from HTTPS or local files with full log-chain verification
- Update documents, rotate keys, change parameters, migrate domains, deactivate
- Witness support with threshold approval; pre-rotation key commitment (
nextKeyHashes); DID portability - Parallel
did:webpublishing; pluggable signing via the asyncSignerinterface
# pubspec.yaml
dependencies:
didwebvh: ^0.1.2
didwebvh_signing_local: ^0.1.2 # only if you use the local-key signerdart pub add didwebvh didwebvh_signing_localThese samples are the target API the port is reproducing — they mirror the Java README so we keep a consistent interface across both libraries. The single intentional difference from Java is that signing is async, so
Signer.signreturns aFutureand everyexecute()is awaited. Examples useLocalKeySignerfromdidwebvh_signing_local; supply your ownSignerto drop in KMS/HSM/etc. All operations are exposed as static entry points onDidWebVh(packagedidwebvh).Builder call styles (library-wide convention). Every configurable operation —
DidWebVh.create,update,migrate, anddeactivate— returns a builder whose optional settings can be supplied in three interchangeable styles: fluent chaining, cascades (..), or named parameters at construction. They are equivalent; pick whichever reads best. The examples below use the fluent form for brevity; the three forms are shown side by side once under Create a DID. The version-selection holderResolveOptionsfollows the same three styles (it mirrors Java'sResolveOptions.Builder).
import 'dart:io';
import 'package:didwebvh/didwebvh.dart';
import 'package:didwebvh_signing_local/didwebvh_signing_local.dart';
final signer = await LocalKeySigner.generate();
final result = await DidWebVh.create('example.com', signer)
.path('dids:alice') // optional URL path segment
.portable(true) // allow future domain migration
.ttl(3600)
.alsoKnownAs(['did:key:z6Mk...']) // optional
.execute();
final did = result.did; // e.g. did:webvh:QmSCID:example.com:dids:alice
final logLine = result.logLine; // write this as the first line of did.jsonlAs noted above, the optional settings can be supplied in three interchangeable styles — pick whichever reads
best; they produce the same result. This applies to every builder operation (create, update, migrate,
deactivate); it is shown here once and not repeated for the others:
// 1. Fluent chaining (shown above; mirrors the Java builder):
await DidWebVh.create('example.com', signer).portable(true).ttl(3600).execute();
// 2. Cascade (idiomatic Dart — the setters work under `..` too):
await (DidWebVh.create('example.com', signer)..portable(true)..ttl(3600)).execute();
// 3. Named parameters at construction:
await DidWebVh.create('example.com', signer, portable: true, ttl: 3600).execute();Save the signer material safely — you need it to sign every subsequent update:
await File('did-secrets.json').writeAsString(signer.toJson());// Remote resolution over HTTPS:
final remote = await DidWebVh.resolve('did:webvh:QmSCID:example.com');
// Offline resolution from an already-downloaded did.jsonl:
final jsonl = await File('did.jsonl').readAsString();
final offline = await DidResolver().resolveFromLog(jsonl, did);
// Time-travel / version filtering (named-parameter form; the fluent and
// cascade builder styles work too, e.g. `ResolveOptions()..versionNumber(3)`):
final opts = ResolveOptions(versionNumber: 3);
final older = await DidResolver().resolveFromLog(jsonl, did, opts);
print(offline.didDocument.toJson());DidWebVhState holds the validated log state and is the input to every update:
final state = await DidWebVhState.fromDidLog(did, jsonl);
// Replace the DID Document (same SCID, same domain):
final rotated = await DidWebVh.update(state, signer)
.newDocument(updatedDoc)
.execute();
// Or change only parameters (e.g. TTL and watchers):
final delta = Parameters()
..ttl = 120
..watchers = ['https://watch.example.com'];
await DidWebVh.update(state, signer).changedParameters(delta).execute();
// Migrate a portable DID to a new domain:
await DidWebVh.migrate(state, signer, 'new.example.com')
.newPath('dids:alice')
.execute();
// Deactivate (permanent):
await DidWebVh.deactivate(state, signer).execute();
// Each result carries the new entry / entries to append to did.jsonl:
final out = File('did.jsonl').openWrite(mode: FileMode.append);
for (final entry in rotated.newEntries) {
out.writeln(entry.toJsonLine());
}
await out.close();Publish a hash of the next authorization key; rotation must reveal that key or the DID becomes unrecoverable:
final nextSigner = await LocalKeySigner.generate();
final nextHash = PreRotationHashGenerator.generateHash(nextSigner.publicKeyMultikey);
await DidWebVh.create('example.com', signer)
.nextKeyHashes([nextHash])
.execute();On the next update, pass the previously-committed key as the current signer and supply a new next-key hash the same way.
Witnesses co-sign each new log entry; their proofs live in did-witness.json next to did.jsonl and MUST be
published first (spec §3.7.8).
final witness = WitnessConfig(
2, // threshold
[
WitnessEntry('did:key:z6MkWitness1...'),
WitnessEntry('did:key:z6MkWitness2...'),
],
);
await DidWebVh.create('example.com', signer).witness(witness).execute();Collecting witness proofs (sign {"versionId":"<id>"} with each authorized witness key and write them to
did-witness.json) is done outside DidWebVh.update; the wizard's witness-proof flow is the reference
implementation (uses ProofGenerator + WitnessProofEntry).
The spec (§3.7.10) lets a did:webvh publisher also serve a plain did:web document at the same URL, so
clients that do not understand did:webvh can still resolve the DID.
final resolved = (await DidResolver().resolveFromLog(jsonl, did)).didDocument;
final webDoc = DidWebPublisher.toDidWeb(resolved);
final didWebUrl = DidWebPublisher.toDidWebUrl(did); // did:webvh:... → did:web:...
const encoder = JsonEncoder.withIndent(' ');
await File('did.json').writeAsString(encoder.convert(webDoc.toJson()));Publish did.json alongside did.jsonl (and did-witness.json if used).
All signing goes through a single async interface (the one intentional delta from Java —
docs/PORTING-DECISIONS.md §4):
abstract interface class Signer {
String get keyType; // e.g. "Ed25519VerificationKey2020"
String get verificationMethod; // did:key:... identifier of the public key
Future<Uint8List> sign(Uint8List data);
}Built-in implementation:
LocalKeySigner(packagedidwebvh_signing_local) — Ed25519 keys from JWK/JSON.LocalKeySigner.generate(),LocalKeySigner.fromJson(json),signer.toJson().
A custom Signer only needs to return the DID-key verification method for its public key and sign the bytes
handed to it. Async makes remote KMS/HSM signers natural:
final class KmsSigner implements Signer {
KmsSigner(this.verificationMethod, this._client); // verificationMethod precomputed from the HSM pubkey
@override
final String verificationMethod;
final KmsClient _client;
@override
String get keyType => 'Ed25519VerificationKey2020';
@override
Future<Uint8List> sign(Uint8List data) =>
_client.signEdDsa(data); // must resolve to a raw 64-byte Ed25519 signature
}Drop it into any DidWebVh.create/update/migrate/deactivate call — the library never touches raw key material.
Install the latest stable wizard from pub.dev and run it from anywhere:
dart pub global activate didwebvh_wizard
didwebvh_wizard # launches the interactive menu in the current directoryOr run it directly from a checkout of this repo (no global install):
cd packages/didwebvh_wizard
dart pub get # first time only
dart run # launches the interactive menu (≡ dart run bin/didwebvh_wizard.dart)The wizard supports:
- Create a new did:webvh DID (keys, pre-rotation, witnesses, watchers, TTL)
- Update an existing DID — modify the document, change any parameter, migrate to a new domain (portable DIDs only), or deactivate; witness proofs are collected automatically when required
- Resolve a did:webvh DID (HTTPS or local file, with optional version filtering)
- Export the parallel
did:webdocument (did.json) for the current DID - Exit
Single-shot mode (skip the menu):
didwebvh_wizard --action export --dir /srv/dids/alice
# from source, name the entrypoint so the flags reach the wizard:
dart run bin/didwebvh_wizard.dart --action export --dir /srv/dids/aliceValid --action values: create, update, resolve, export. Use --dir to point at the directory holding
did.jsonl (defaults to the current directory); --help lists every option.
dart pub get
dart analyze # very_good_analysis, zero issues
dart test # all tests incl. the shared interop vectorsContributing to the port: read docs/PORTING-GUIDE.md and
docs/AGENTS.md, set up the git-ignored Java reference
(reference/README.md), then run the next iteration with
docs/PROMPT.md. Every commit requires a human review.
Releasing to pub.dev (including first-time account setup) is documented in
docs/PUBLISHING.md.
All three packages are versioned in lockstep. To cut a release, run the interactive wizard:
tool/bump-version.sh # patch / minor / major or an explicit versionIt updates every pubspec.yaml, the inter-package ^ constraints, the install snippet above, the wizard's
generated version.g.dart, and all changelogs (promoting Unreleased → the new dated version), then prints the
git tag command. Pushing the vX.Y.Z tag publishes to pub.dev and creates a GitHub Release whose notes come from
the root CHANGELOG.md section for that version.
Changelog conventions: the root CHANGELOG.md follows
Keep a Changelog (## [Unreleased], ## [X.Y.Z] - DATE); each package's
CHANGELOG.md uses the plain Dart-idiomatic style (## Unreleased, ## X.Y.Z - DATE). pub.dev accepts both.
This is a faithful port; the intentional deltas are documented in
docs/PORTING-DECISIONS.md (chiefly the async Signer). One behavioural caveat
worth calling out:
- Internationalized domain names (IDN) are not fully RFC 3491 (Nameprep) compliant. When transforming a
did:webvhDID to its HTTPS URL, the host is Unicode-normalized (NFC) and converted to ASCII via IDNA/Punycode, mirroring Java'sNormalizer.normalize(NFC)+IDN.toASCII. All-ASCII hosts (including already-Punycodedxn--labels) pass through byte-for-byte identically to Java. Non-ASCII labels, however, are nameprepped with an approximation — NFKC + lowercase — rather than the full RFC 3491 stringprep mapping/prohibition tables thatjava.net.IDNapplies. For such domains the producedxn--label may differ from the Java output. No did:webvh test vector exercises a non-ASCII domain, and ASCII/xn--domains (the common case) are unaffected. If you need exact RFC 3491 parity for raw Unicode domains, supply the host in its already-ASCII (xn--) form.