A .NET 10 implementation of the W3C ZCAP-LD authorization capability model.
ZCAP-LD uses object capabilities: if you hold a valid signed capability, you have authority to invoke the permitted action.
This library provides:
- Capability creation and delegation
- Delegation-chain verification
- Invocation signing and verification
- Native restrictions (
expires,allowedAction, target narrowing); unregistered caveats are rejected. - Opt-in custom caveats with signed parameters and resource-owned enforcement (caveat profile; not interoperable with other ZCAP stacks)
- Cross-language wire-format compatibility — proven live against
@digitalbazaar/zcap(RDFC-1.0 / W3C Data Integrity; seeinterop/) - Invocation replay protection with pluggable nonce stores
- Revocation persistence with pluggable storage backends
- Ed25519 and P-256 signature suites with RDFC-1.0 canonicalization (crypto delegated to NetCrypto via DataProofs); P-256 is a zcap-dotnet-only profile, see the cross-stack notes below
- DID resolution via NetDid (W3C-compliant did:key support)
- Multibase signature encoding
dotnet add package ZcapLd.Core
dotnet add package ZcapLd.AspNetCore # Optional endpoint adapterZcapLd needs reflection-based System.Text.Json, so native AOT, trimmed apps and .NET 10
file-based apps (dotnet run app.cs) are not supported yet: the first delegation throws
InvalidOperationException ("Reflection-based serialization has been disabled…"), and nothing
is authorized. Use a regular net10.0 project (#154).
using ZcapLd.Core.Models;
using ZcapLd.Core.Services;
// Supply your IDidSigner and IDidResolver implementations (for example, HSM/KMS-backed).
// For several DID methods, see "DID methods and controller authorization" below.
var signingService = new SigningService(didSigner, didResolver);
var capabilityService = new CapabilityService(signingService);
var verificationService = new VerificationService(didResolver);
var root = await capabilityService.CreateRootCapabilityAsync(ownerDid, "https://example.com/reports");
var delegated = await capabilityService.DelegateCapabilityAsync(
root, agentDid, new[] { "read" }, DateTime.UtcNow.AddDays(1));
var invocation = capabilityService.CreateInvocation(delegated, "read");
invocation.Proof = await signingService.SignInvocationAsync(invocation, agentDid);
bool valid = await verificationService.VerifyInvocationAsync(invocation, delegated, root);The explicit root above supplies the trust anchor. Alternatively configure
IRootCapabilityResolver. For cross-stack application-document invocations use
SignCapabilityInvocationAsync and VerifyCapabilityInvocationAsync with the
relying party's expected action and targets. Run the console example for both paths.
Version 6 restores custom caveats as an opt-in extension profile whose parameters are signed
(see Custom caveats). It also signs envelope created and
requires normalized ids. See the breaking changes.
Per W3C ZCAP-LD v0.4.0, a capability's controller may be a single DID or an array of DIDs.
Capability.Controller is a ControllerSet that preserves the shape on the wire — assign a
string for one controller or a string[] for several (implicit conversions). Any controller
in the set is authorized to invoke or delegate.
// Single controller → serializes as a bare string: "controller": "did:key:zAlice"
var single = await capabilityService.CreateRootCapabilityAsync(
"did:key:zAlice",
"https://api.example.com/resources",
new[] { "read" });
// Multiple controllers → serializes as an array: "controller": ["did:key:zAlice","did:key:zBob"]
var shared = await capabilityService.CreateRootCapabilityAsync(
new[] { "did:key:zAlice", "did:key:zBob" },
"https://api.example.com/resources",
new[] { "read" });
// An invocation/delegation signed by EITHER zAlice or zBob verifies; any other key is rejected.
shared.Controller.Values; // ["did:key:zAlice", "did:key:zBob"]
shared.Controller.ContainsVerificationMethod("did:key:zBob#zBob"); // true
// When delegating from a multi-controller capability, choose which controller signs:
var delegatedByBob = await capabilityService.DelegateCapabilityAsync(
shared,
"did:key:zCarol",
new[] { "read" },
DateTime.UtcNow.AddDays(7),
signerDid: "did:key:zBob"); // must be one of `shared`'s controllersDidKeyResolver handles did:key. For another method, implement IDidResolver (key material) and
NetDid's IVerificationRelationshipResolver (which keys the controller's DID document lists under
capabilityInvocation / capabilityDelegation) in one adapter, then register adapters in a
CompositeDidResolver using full method prefixes with the trailing colon:
var resolver = new CompositeDidResolver();
resolver.Register("did:key:", new DidKeyResolver());
resolver.Register("did:webvh:", myWebVhAdapter); // IDidResolver + IVerificationRelationshipResolver
var verificationService = new VerificationService(resolver); // DI: services.AddZcapDidResolver(_ => resolver)The composite routes key resolution and controller authorization to the adapter registered for the
controller's DID method, so no separate relationship resolver is needed. It fails closed: a
controller whose method has no adapter is denied (methodNotSupported, logged at Debug), and an
adapter that resolves keys only is denied with internalError (logged at Warning) rather than
having authority inferred from key resolution. An explicit relationshipResolver constructor
argument or DI registration takes precedence over what the resolver self-provides.
The delegation proof's capabilityChain follows the W3C ZCAP-LD shape exactly (Issue #50): the
root is referenced by id only (never embedded), every ancestor is referenced by id, and only the
immediate parent delegated zcap is fully embedded — as the last entry. So:
| Delegation level | capabilityChain |
|---|---|
| First (from root) | ["urn:zcap:root:…"] |
| Second | ["urn:zcap:root:…", { …parent embedded… }] |
| Third | ["urn:zcap:root:…", "urn:uuid:first…", { …parent embedded… }] |
Because the root is by-reference, the verifier needs a way to obtain it to authorize the first delegation. Provide it one of two ways:
- Resolver — implement
IRootCapabilityResolver(the resource owner resolves roots from its own store) and pass it toVerificationService(or register it in DI withAddZcapRootCapabilityResolver<T>()). AnIDidResolverthat also implementsIRootCapabilityResolveris auto-detected.InMemoryRootCapabilityResolveris provided for dev. - Explicit root — call the overloads that take the root directly:
VerifyCapabilityChainAsync(cap, root),VerifyInvocationAsync(invocation, cap, root),VerifyCapabilityProofAsync(cap, root),RevokeCapabilityAsync(cap, signedRevocation, root).
When neither is available the verifier fails closed. The verifier also rejects non-spec chains (an embedded root, duplicated ids, a parent referenced both by id and embedded, a wrong/missing embedded parent).
Breaking change (3.0.0): the
capabilityChainwire format changed (it no longer embeds the root), so capabilities signed by earlier versions do not verify and must be re-delegated; and verifying a delegated capability now requires a resolver or an explicit root as described above.Breaking change (4.0.0): the cryptography and canonicalization are now delegated to the composable foundation — NetCrypto (via NetDid 2.0.0), DataProofsDotnet.Rdfc, and NetCid. This is source-breaking (recompile; the
NetDid.Core.Crypto.*types moved toNetCrypto.*) but wire-compatible: theEd25519Signature2020/EcdsaSecp256r1Signature2019proof bytes are unchanged, so capabilities issued by 3.x still verify — no re-delegation.Breaking change (6.0.0): DataProofs 2.0.0 corrects RDFC-1.0 tied-blank-node labelling. A delegation whose
capabilityChainembeds a co-signed parent (two or more delegation proofs) canonicalizes those proofs as tied blank nodes, so a child signed by 5.x over such a parent may returnInvalidSignatureunder 6.0 (about half do). Re-delegate affected children under 6.0, and upgrade verifiers before issuers: a 5.x verifier and a 6.0 verifier disagree on them. Single-proof chains, co-signed parents themselves and golden vectors are unchanged. Envelope invocations and signed revocations signed by 5.x also do not verify under 6.0 (see the CHANGELOG).
Revocation requires proof of possession: a revoker proves control by signing a revocation request, which the library authenticates (signature) and authorizes (against the capability's verified delegation chain). There is no unauthenticated bare-DID path.
ISigningService.SignRevocationAsync(...): mint a signed revocation requestIVerificationService.RevokeCapabilityAsync(Capability, Invocation): authenticate + authorize + recordIRevocationStore: plug in your own backend (database, contract gateway, oracle bridge, cache)IRevocationService: persistence primitive — performs no auth; never call directly from untrusted inputVerificationService: checks revocation status during capability/invocation verification
// Revoker side — sign a revocation request bound to the capability:
var signedRevocation = await signingService.SignRevocationAsync(
delegated.Id, revokerDid, delegated.InvocationTarget, reason: "key compromised");
// Verifier side — authenticate, authorize against the chain, then record:
bool revoked = await verificationService.RevokeCapabilityAsync(delegated, signedRevocation);Optional ASP.NET endpoint rails are provided by ZcapLd.AspNetCore:
using ZcapLd.AspNetCore.DependencyInjection;
using ZcapLd.AspNetCore.Endpoints;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddZcapServices(); // registers IVerificationService (required by the POST endpoint)
// Optionally override the store: builder.Services.AddZcapRevocationSupport<MyStore>();
var app = builder.Build();
app.MapZcapRevocationEndpoints(); // /zcaps/revocations/{*capabilityId}
app.Run();Use ZcapLd.AspNetCore when your runtime is ASP.NET and you want ready-made minimal API rails:
- Register services via
AddZcapServices()(the POST/revoke endpoint resolvesIVerificationService) - Map routes via
MapZcapRevocationEndpoints(...) - POST a signed
{ capability, signedRevocation }; the endpoint returns403if unauthenticated/unauthorized - Override route prefix when needed (for example
/wallet/revocations) - Address a capability as
/zcaps/revocations/{encodeURIComponent(capabilityId)}; the endpoint decodes it exactly once, so a root id (urn:zcap:root:https%3A%2F%2F…) is encoded twice in the URL - Revoking a root over
POSTauthorizes against the root the server'sIRootCapabilityResolverreturns, never the body's copy - There is no status GET (the spec and the JS stack define only the POST). Records are scoped to the
delegator that signed the capability (#158); holders learn of revocation from a
Revokedoutcome
If you do not want ASP.NET endpoints, drive IVerificationService.RevokeCapabilityAsync(Capability, Invocation) from your own transport layer (gRPC handler, message consumer, admin CLI, worker). Call
the lower-level IRevocationService directly only after you have authenticated and authorized the
caller yourself — it records whatever it is given.
Configure storage via IRevocationStore:
InMemoryRevocationStorefor local development/testing- database-backed custom stores for centralized persistence
- smart-contract/oracle-backed stores for decentralized persistence
- hybrid cache + durable store composites for high-throughput workloads
Full developer guide: docs/REVOCATION-INTEGRATION.md
The supported ZCAP v0.4.0 profile
uses expires, allowedAction, and invocationTarget to restrict delegated authority.
Without a caveat registry, caveat fields are rejected, including unknown types, empty arrays
and null values, throughout embedded chains. Equivalent absolute-IRI/prefixed keys and nested
restriction nodes are rejected too. Capability/proof contexts are limited to zcap/v1, the
supported proof-suite contexts and, when enabled, the generated caveat contexts. Inline aliases
are unsupported. Profile inspection is bounded to depth 64 and 65,536 visited values per scan;
exceeding those limits produces a distinct resource-limit diagnostic. There is no remote
ValidWhileTrue handler and no capability-supplied URL is ever fetched.
var child = await capabilityService.DelegateCapabilityAsync(
root, agentDid, new[] { "read" }, DateTime.UtcNow.AddDays(1),
invocationTarget: root.InvocationTarget + "/reports?section=summary");
// Register before AddZcapServices(), or pass policy: to VerificationService.
services.AddSingleton(new VerificationPolicy { AllowTargetAttenuation = true });AllowTargetAttenuation defaults to false, matching @digitalbazaar/zcap:
parent/child and capability/invocation targets must match exactly. When enabled,
a suffix must begin with / or ? if the base has no query, or & if it does.
The verifier additionally rejects literal .. path segments. Creation allows an
explicit narrower target; verifier acceptance is a separate policy decision.
The resource API must enforce the meaning of each restriction. Lexical matching
cannot enforce usage counters, quotas, or query semantics. In particular, reject
ambiguous duplicate restriction parameters: ?maxUses=1&maxUses=100 must not
widen authority through last-value parsing. Proxies and resource routing must
interpret the authenticated target consistently, including percent encoding.
Use native expires instead of ExpirationCaveat. Revocation belongs to the
verifier responsible for the target; see revocation integration.
Some restrictions do not fit expires, allowedAction or a target. A cumulative
differential-privacy budget is one example. Deployments that need such restrictions can enable
the caveat extension profile. Capabilities carrying caveats are
rejected by @digitalbazaar/zcap and by verifiers that have not registered the types.
public sealed class EpsilonBudgetCaveat : Caveat
{
public override string Type => "EpsilonBudget";
[JsonPropertyName("maxEpsilon")] public required double MaxEpsilon { get; init; }
public override bool IsAttenuationOf(Caveat parent) // a child may only lower it
=> parent is EpsilonBudgetCaveat p && MaxEpsilon <= p.MaxEpsilon;
}
var caveats = new CaveatTypeRegistry().Register<EpsilonBudgetCaveat>("EpsilonBudget");
var signing = new SigningService(signer, resolver, caveats);
var capabilities = new CapabilityService(signing, caveats);
var grant = await capabilities.DelegateCapabilityAsync(parent, agentDid, new[] { "query" },
DateTime.UtcNow.AddHours(4), caveats: new Caveat[] { new EpsilonBudgetCaveat { MaxEpsilon = 3 } });
// The resource enforces: one ICaveatHandler per type, run after every other check.
var verifier = new VerificationService(resolver, revocations, nonces,
caveats: caveats, caveatHandlers: new ICaveatHandler[] { new EpsilonBudgetLedger(store) });- Every parameter is signed. Each type gets a pinned context, and undeclared parameters, unregistered types and non-canonical numbers are rejected rather than silently dropped.
- Every delegated link must restate and narrow each parent caveat; the issuer inherits unrestated ones.
- Only invocation verification authorizes. Chain and proof checks return
CaveatEnforcementRequiredwith the authenticated grants. A type without a handler isUnsupportedCaveat. - The resource owns cost calculation, atomic hierarchical accounting (debit every grant by
GrantKey), idempotency by invocation id, and shared state across replicas. See the enforcement contract.
- Root capability:
- Contains
@context,id,controller,invocationTarget - Does not include
proof,expires,parentCapability
- Contains
- Delegated capability:
- Contains
parentCapability,expires, delegationproof - Supports attenuation (
allowedAction, target narrowing, expiration)
- Contains
src/ZcapLd.Core: library codesrc/ZcapLd.AspNetCore: optional ASP.NET endpoint adapter packagetests/ZcapLd.Core.Tests: unit, integration, and compliance testsexamples/ZcapLd.Examples: native restriction and signed revocation examplesexamples/ZcapLd.RevocationEndpointsDemo: ASP.NET revocation with SQLiteinterop: live cross-stack interop harness vs@digitalbazaar/zcap(runinterop/run-interop.sh)docs: implementation/security notes
- Architecture:
architecture.md - Interop & spec-compliance analysis:
docs/ZCAP-LD-INTEROP-COMPATIBILITY-ANALYSIS.md(with live@digitalbazaar/zcapround-trip harness ininterop/) - Revocation Integration:
docs/REVOCATION-INTEGRATION.md - Contributing:
CONTRIBUTING.md - NuGet Release Runbook:
docs/NUGET-RELEASE.md - Monorepo Pipelines:
docs/MONOREPO-PIPELINES.md - MIT License:
LICENSE
dotnet restore
dotnet build ZcapLd.sln
dotnet test ZcapLd.sln
dotnet pack src/ZcapLd.Core/ZcapLd.Core.csproj -c Release
dotnet pack src/ZcapLd.AspNetCore/ZcapLd.AspNetCore.csproj -c Release- Core CI:
.github/workflows/ci-core.yml - ASP.NET Adapter CI:
.github/workflows/ci-aspnet.yml - Core publish:
.github/workflows/release-core-nuget.ymloncore-v*.*.*tags - ASP.NET adapter publish:
.github/workflows/release-aspnet-nuget.ymlonaspnet-v*.*.*tags - Shared package version source:
Directory.Build.props(ZcapLdVersion) - Publishing: nuget.org Trusted Publishing with one policy per release workflow; the repository stores no API key (
docs/NUGET-RELEASE.md)
- No default
IDidSignerships in the core package — consumers must provide their own (HSM/KMS/Key Vault). InMemoryDidProvider(in examples and tests) stores private keys in plaintext memory and is NOT for production use.- Canonicalization is RDFC-1.0 (W3C RDF Dataset Canonicalization) only — the format that interoperates with
@digitalbazaar/zcap. JCS support was removed in 4.0.0; there is no canonicalization option. Known W3C JSON-LD contexts are embedded for offline operation. The legacy suites are constructed to verify the RDFC-1.0 variant only (LegacyVariantVerification.ConstructedVariantOnly, DataProofs 2.0.0), so a proof made with DataProofs' JCS variant of the same suite type does not verify (#159). - P-256 resolvers must return the compressed 33-byte SEC1 point (an uncompressed key never verifies), and P-256
proofValues are not low-s normalized: a valid(r, s)has a second valid form(r, n−s). Nothing in zcap keys on proof bytes (replay uses the invocation id, revocation the delegator and capability id), so the second form is neither a replay nor a bypass. - Cryptography is provided by NetCrypto (Ed25519 / P-256 sign + verify), reached via NetDid 4.0.0 for W3C-compliant did:key resolution; multibase via NetCid; RDFC-1.0 via DataProofsDotnet.Rdfc.
- Ed25519 and P-256 are the supported signature suites; cryptography is delegated to NetCrypto (via DataProofs), not extended through a zcap API. A P-256 capability carries zcap-dotnet's own suite context,
urn:zcap-dotnet:ecdsa-secp256r1-2019-context:v1(theed25519-2020/v1shape with the P-256 names, embedded and never fetched), because no suite context is published forEcdsaSecp256r1Signature2019: the…/suites/ecdsa-2019/v1URL of the 2022 CG draft does not resolve (#159).
Capability delegation interoperates with @digitalbazaar/zcap (proven live by interop/). Two things to know for cross-stack use:
- Use
Ed25519Signature2020. P-256 (EcdsaSecp256r1Signature2019) is a zcap-dotnet-only profile:@digitalbazaar/zcaphas no such suite (it ships/tests Ed25519 only), the base58-btcproofValueover an IEEE P1363 signature is zcap-dotnet's convention, and the capability's suite context is zcap-dotnet's own, so a P-256 capability will not verify there. - Invocation interop works via Data Integrity
capabilityInvocationproofs (SignCapabilityInvocationAsync/VerifyCapabilityInvocationAsync), proven cross-stack for root + delegated. The verifier requires the relying party to declare the expected action and target(s) it authorizes (as the reference impl does) and validates the application document's@context, so a valid invocation can't be replayed against a different endpoint or have its terms rewritten. HTTP-Signature invocations (theezcaptransport) are not yet supported (tracked in #119).