From dfcf7d649a06a918073897bc6fcbb62d6ace0469 Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Fri, 19 Jun 2026 08:04:46 -0400 Subject: [PATCH 1/4] feat(invocation): @digitalbazaar/zcap-compatible Data Integrity invocations (Path A, #117) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an ADDITIVE digitalbazaar-compatible invocation mode and proves it live both directions against the real @digitalbazaar/zcap. The existing self-contained Invocation envelope (used in-stack and for revocation) is unchanged. - SigningService.SignCapabilityInvocationAsync(...): signs an application document with a capabilityInvocation proof whose proof object alone carries capability/capabilityAction/ invocationTarget (no envelope, no capabilityChain). The document carries @context=[zcap/v1, ed25519-2020/v1] + id + an absolute-IRI type (required so it expands to a non-empty RDF dataset under JSON-LD safe mode). Signed input = SHA-256(RDFC(proofOptions)) || SHA-256(RDFC(document)) via the existing LegacyProofCrypto path. - VerificationService.VerifyCapabilityInvocationAsync(JsonObject[, root]): verifies the signature over the application document, then reuses the chain/attenuation/caveat/controller/freshness/ replay checks. Root id-string vs embedded-delegated-zcap shapes handled per #51. - interop: CLI gen-invocation/verify-invocation; JS invoke-gen.mjs/invoke-verify.mjs + lib.mjs; run-interop.sh now runs 12 checks (6 delegation + 6 invocation: root + delegated, both directions, + 2 tamper negatives) — all green. - tests: DataIntegrityInvocationTests (root + delegated round-trip, tampered-action, action-not-allowed). - docs: README/AGENTS/CHANGELOG/interop README/roadmap updated. HTTP-Signature invocations (Path B) remain deferred (#119). 460 Core + 33 AspNetCore tests green; interop/run-interop.sh 12/12. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 1 + CHANGELOG.md | 12 + README.md | 2 +- docs/ZCAP-LD-ROADMAP.md | 4 +- interop/README.md | 28 +-- interop/ZcapLd.Interop/Program.cs | 90 +++++++ interop/js/invoke-gen.mjs | 229 ++++++++++++++++++ interop/js/invoke-verify.mjs | 105 ++++++++ interop/js/lib.mjs | 140 ++++++++++- interop/run-interop.sh | 55 ++++- src/ZcapLd.Core/Services/SigningService.cs | 98 ++++++++ .../Services/VerificationService.cs | 175 +++++++++++++ tasks/todo-117-path-a-invocation.md | 49 ++++ .../DataIntegrityInvocationTests.cs | 102 ++++++++ 14 files changed, 1061 insertions(+), 29 deletions(-) create mode 100644 interop/js/invoke-gen.mjs create mode 100644 interop/js/invoke-verify.mjs create mode 100644 tasks/todo-117-path-a-invocation.md create mode 100644 tests/ZcapLd.Core.Tests/Integration/DataIntegrityInvocationTests.cs diff --git a/AGENTS.md b/AGENTS.md index 0ff10c9..2763663 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,6 +97,7 @@ dotnet run --project examples/ZcapLd.Examples # Run con - **Spec-exact `capabilityChain` (#50)**: root referenced by id only (never embedded); first-level chain is exactly `[rootId]`; deeper chains are `[rootId, …ancestorIds, {immediateParent}]`. The verifier rejects non-spec shapes (embedded root, duplicate ids, parent both id+embedded, wrong/missing embedded parent) - **Root resolution (#50)**: spec-exact chains reference the root by id, so the verifier obtains it via an explicit-root verify/revoke overload, else an `IRootCapabilityResolver` (`InMemoryRootCapabilityResolver` for dev; an `IDidResolver` that also implements it is auto-detected), else fails closed. **Breaking (3.0.0)**: wire format no longer embeds the root, so pre-3.0.0 capabilities must be re-delegated - **Invocation `capability` shape (#51)**: `InvocationCapability` (union of root-id **string** | full embedded delegated **zcap object**) backs both `Invocation.Capability` and the invocation `Proof.Capability`, with `InvocationCapabilityJsonConverter` preserving the wire shape. Per ZCAP-LD v0.3 a delegated DI invocation MUST embed the full delegated zcap (`InvocationCapability.FromCapability(delegated)`); a root invocation uses the id string (implicit `string` conversion). Verification is **strict**: a delegated invocation supplying only an id string is rejected. Revocation requests still reference the capability by id string. **Breaking**: `Invocation.Capability` is no longer `string`; `Proof.Capability` is no longer `object?` +- **DI invocation interop — Path A (#117)**: in addition to the self-contained `Invocation` envelope (above; kept for in-stack use + revocation), there is an **additive** `@digitalbazaar/zcap`-compatible mode. `SigningService.SignCapabilityInvocationAsync(capability, action, target, signerDid, appDoc?)` signs an **application document** (default carries `@context=[zcap/v1, ed25519-2020/v1]` + an `id` + an absolute-IRI `type` — both required so the doc expands to a non-empty RDF dataset under JSON-LD safe mode) with a `capabilityInvocation` proof whose proof object ALONE holds `capability`/`capabilityAction`/`invocationTarget` (no envelope, no `capabilityChain`). Signed input = `SHA-256(RDFC(proofOptions)) || SHA-256(RDFC(document))` via `LegacyProofCrypto` (proof-options `@context` spliced from the document by DataProofs). `VerificationService.VerifyCapabilityInvocationAsync(JsonObject securedDocument[, root])` verifies the signature over the application doc, then reuses the chain/attenuation/caveat/controller/freshness/replay checks. Proven live both directions (root + delegated) by `interop/run-interop.sh` checks 7–12. HTTP-Signature invocations (ezcap, "Path B") are deferred (#119). - **Revocation**: `IRevocationService` / `IRevocationStore` abstractions; `InMemoryRevocationStore` for dev; ASP.NET endpoints via `ZcapLd.AspNetCore` - **Replay protection**: `INonceStore` interface; `InMemoryNonceStore` (default), `NullNonceStore` (opt-out) - **Proof `created` freshness (#71)**: both the invocation (`VerifyInvocationCoreAsync`) and signed-revocation (`RevokeCapabilityInternalAsync`) paths reject a proof whose signed `created` is missing/unparseable, future-dated beyond a configurable clock skew (`_freshnessClockSkew`, default `DefaultFreshnessClockSkew` = 1 min, set via the `freshnessClockSkew` ctor param), or older than `_nonceWindow` — via the shared `GetFreshProofCreatedUtc` helper (staleness bound reuses the nonce window, so anything still evictable is already too stale; widening `nonceWindow` widens both replay window and acceptable signing age). Bounds replay independently of nonce-store eviction (and even under `NullNonceStore`). The invocation path feeds the validated `created` into `InvocationContext.InvocationTime` (caveats evaluate against signed time). Detailed reason: `VerificationOutcome.StaleProof`. Test seam: determinism overloads `SignInvocationAsync(.., createdOverride)` / `SignRevocationAsync(.., createdOverride)` on the concrete `SigningService` (not on `ISigningService`). **Delegation-proof** `created` freshness is a separate concern (durable proofs, no staleness bound) — see #99 below diff --git a/CHANGELOG.md b/CHANGELOG.md index 017ff1c..767ec12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,18 @@ Delegates zcap's cryptography and canonicalization to the composable foundation makes the wire format interoperable with the `@digitalbazaar/zcap` reference implementation. Includes everything in the (unreleased) 3.0.0 section below. +### Added + +- **`@digitalbazaar/zcap`-compatible Data Integrity invocations ("Path A", #117).** New + `SigningService.SignCapabilityInvocationAsync(...)` produces a secured application document with a + `capabilityInvocation` proof whose proof object alone carries `capability`/`capabilityAction`/ + `invocationTarget` (no self-contained envelope), and `VerificationService.VerifyCapabilityInvocationAsync(...)` + verifies one (signature over the application document, then chain/attenuation/caveats/controller/ + freshness/replay). Round-trips live against the real `@digitalbazaar/zcap` `CapabilityInvocation` + purpose — root **and** delegated, both directions — in `interop/run-interop.sh` (checks 7–12). This is + **additive**: the existing self-contained `Invocation` envelope (used in-stack and for revocation) is + unchanged. HTTP-Signature invocations (the `ezcap` transport, "Path B") remain a follow-up (#119). + ### Breaking Changes - **Canonicalization is RDFC-1.0 only — JCS support removed.** RDFC-1.0 (W3C RDF Dataset Canonicalization) is now the sole canonicalization, which is what makes proofs verify under `@digitalbazaar/zcap` (proven live by `interop/run-interop.sh`). `SigningService` and `VerificationService` no longer accept a `canonicalizationMethod` argument; the `AddZcapRdfcCanonicalization()` DI toggle and the `JcsDocumentCanonicalizer` / `JsonCanonicalizer` types are removed. **No backwards compatibility:** JCS-signed capabilities issued by ≤3.x do not verify and must be re-signed under RDFC-1.0. (See the revocation-integrity entry below for one behavioral consequence.) diff --git a/README.md b/README.md index e07e3d7..2eef77a 100644 --- a/README.md +++ b/README.md @@ -335,4 +335,4 @@ Capability **delegation** interoperates with `@digitalbazaar/zcap` (proven live - **Use `Ed25519Signature2020`.** P-256 (`EcdsaSecp256r1Signature2019`) has no `@digitalbazaar/zcap` counterpart — it ships/tests Ed25519 only and serves no `ecdsa-2019/v1` context, so a P-256 capability will not verify there. - **Custom caveats are not enforced cross-stack.** `@digitalbazaar/zcap` has no generic caveat engine, so a capability whose security depends on `UsageCountCaveat`/`ValidWhileTrueCaveat` verifies there with the caveat **silently un-enforced**. For cross-stack security guarantees, rely only on `expires`/`allowedAction`/`invocationTarget`. -- **Invocation** interop is not yet supported (tracked in the roadmap / issue #117) — delegation only, today. +- **Invocation** interop works via Data Integrity `capabilityInvocation` proofs (`SignCapabilityInvocationAsync` / `VerifyCapabilityInvocationAsync`), proven cross-stack for root + delegated. HTTP-Signature invocations (the `ezcap` transport) are not yet supported (tracked in #119). diff --git a/docs/ZCAP-LD-ROADMAP.md b/docs/ZCAP-LD-ROADMAP.md index ea160a4..2d168f8 100644 --- a/docs/ZCAP-LD-ROADMAP.md +++ b/docs/ZCAP-LD-ROADMAP.md @@ -79,8 +79,8 @@ All four are reachable from the invocation path via chain dereference and are ex | **1** | **Verify-path `@context` value + order + suite-presence** (R-CTX-1/2 + NEW) | **S** | — | Unit tests: a hand-crafted root with `@context != zcap/v1` and a delegated with `@context[0] != zcap/v1` (or missing suite context) are rejected with `MalformedCapability`; all existing delegation interop cases still pass. | | **2** | **Verify-path root extra-field rejection** (R-ROOT-NOEXTRA) | **S** | — (independent of #1) | A wire/resolver root carrying `expires`/`allowedAction`/`caveat`/extra `AdditionalProperties` is rejected on the crypto verify path; parity with CapabilityService.cs:262-271 proven by test. | | **3** | **Doc-only divergence callouts** (P-256, custom caveats) | **S** | — | README.md + XML doc-comments carry the cross-stack warnings; `docs/ZCAP-LD-INTEROP-COMPATIBILITY-ANALYSIS.md` items 7-style notes marked done. | -| **4** | **Invocation DI envelope — sign + verify** (#117 / R-INV-ENVELOPE + R-INV-RDFC-CONTEXT) | **L** | #1 (reuses the `@context` value check on the proof) | zcap-dotnet emits a `{applicationDocument, proof}` DI invocation; `interop/js/invoke-verify.mjs` `jsigs.verify` returns `verified: true`; reverse (`jsigs.sign` → dotnet verify) passes; tamper rejects. | -| **5** | **Invocation interop wired into harness** | **M** | #4 | `interop/run-interop.sh` round-trips an invocation **both directions** (dotnet→db, db→dotnet) plus tamper-reject, alongside the existing delegation cases; CI green. | +| **4** | ✅ **DONE (#117)** — **Invocation DI envelope — sign + verify** | **L** | #1 | `SigningService.SignCapabilityInvocationAsync` emits a `{applicationDocument, proof}` DI invocation (metadata only in the proof; doc carries `@context=[zcap/v1, ed25519-2020/v1]` + `id` + absolute-IRI `type`); `VerificationService.VerifyCapabilityInvocationAsync` verifies one. Proven against the real `@digitalbazaar/zcap` `CapabilityInvocation` purpose, root + delegated, both directions, + tamper reject. | +| **5** | ✅ **DONE (#117)** — **Invocation interop wired into harness** | **M** | #4 | `interop/run-interop.sh` checks 7–12 round-trip a DI invocation both directions (root + delegated) + tamper-reject; all 12 checks green. | | **6** | **HTTP-Signature invocation envelope** (`ZcapHttpSignature` builder/verifier — Path B) | **L** | #4 (shares the `CapabilityInvocation` validation semantics; needs new npm deps) | New harness case verifies dotnet-emitted `capability-invocation`+`authorization` headers via `@digitalbazaar/http-signature-zcap-verify`; root + delegated (gzip+base64url) + base64 signature + digest header round-trip. | **Rationale:** #1-#3 are quick, independent spec/doc closeouts that can land immediately and raise compliance without touching the envelope. #4 is the keystone (the actual interop blocker) and depends on the proof-`@context` value check from #1. #5 proves #4 end-to-end. #6 delivers the dominant real-world transport but is a clean follow-up requiring no crypto-core changes. diff --git a/interop/README.md b/interop/README.md index e0553d5..2dcaa51 100644 --- a/interop/README.md +++ b/interop/README.md @@ -2,9 +2,9 @@ This harness **proves at runtime** that zcap-dotnet's RDFC-1.0 path is wire-compatible with the [`@digitalbazaar/zcap`](https://github.com/digitalbazaar/zcap) v9 reference -implementation (the spec authors' library) for **capability delegation proofs** — by -signing a real capability on one stack and verifying it on the other, in both -directions, including a tamper-detection negative control and a two-level chain. +implementation (the spec authors' library) for **capability delegation AND Data Integrity +invocation** — by signing a real capability/invocation on one stack and verifying it on the +other, in both directions, including tamper-detection negative controls and a two-level chain. It is the empirical counterpart to [`docs/ZCAP-LD-INTEROP-COMPATIBILITY-ANALYSIS.md`](../docs/ZCAP-LD-INTEROP-COMPATIBILITY-ANALYSIS.md), which inferred RDFC byte-compatibility from decompiled internals + golden-vector @@ -21,20 +21,20 @@ hashes. This harness replaces inference with an actual `@digitalbazaar/zcap` | 4 | **Negative (inbound)** — tampered `allowedAction` on a db zcap → dotnet rejects | FAIL | | 5 | **Multi-level outbound** — dotnet 2-level chain `[rootId, {parent}]` → db verifies | PASS | | 6 | **Multi-level inbound** — db 2-level chain → dotnet verifies | PASS | +| 7–10 | **Invocation** (DI `capabilityInvocation`) — root + delegated, both directions | PASS | +| 11–12 | **Invocation negatives** — tampered `capabilityAction`, both directions | FAIL | ## Scope -- **In scope:** capability **delegation** proofs (`proofPurpose: capabilityDelegation`), - Ed25519Signature2020 over RDFC-1.0, did:key controllers. This is the dominant - interop path (the canonicalization blocker, #1 in the analysis report). -- **Out of scope:** **invocations**. zcap-dotnet signs a self-contained - `{id, capability, capabilityAction, invocationTarget}` envelope as the document, - whereas digitalbazaar signs an arbitrary application payload with the invocation - metadata only in the proof — a structural mismatch (blocker #2) independent of - canonicalization. Not exercised here. -- The default zcap-dotnet canonicalization is **JCS**, which is *not* interoperable; - this harness explicitly uses **RDFC-1.0** on both the signer and verifier. The - takeaway: RDFC-1.0 is the interop mode. +- **Delegation** proofs (`proofPurpose: capabilityDelegation`), Ed25519Signature2020 over + RDFC-1.0, did:key controllers — the canonicalization path (#1 in the analysis report). +- **Invocation** via **Data Integrity `capabilityInvocation` proofs** ("Path A", #117): a + signed application document whose proof alone carries `capability`/`capabilityAction`/ + `invocationTarget`. zcap-dotnet's `SignCapabilityInvocationAsync` / `VerifyCapabilityInvocationAsync` + round-trip against the real `@digitalbazaar/zcap` `CapabilityInvocation` purpose, root + delegated. + The legacy self-contained `Invocation` envelope remains for in-stack use + revocation. +- **Not yet covered:** HTTP-Signature invocations (the deployed `ezcap` transport — Path B). The + whole stack is **RDFC-1.0 only** (JCS was removed in 4.0.0); RDFC-1.0 is the interop format. ## Run it diff --git a/interop/ZcapLd.Interop/Program.cs b/interop/ZcapLd.Interop/Program.cs index cec9b76..75c8409 100644 --- a/interop/ZcapLd.Interop/Program.cs +++ b/interop/ZcapLd.Interop/Program.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using ZcapLd.Core.Cryptography; using ZcapLd.Core.Models; using ZcapLd.Core.Services; @@ -52,6 +53,22 @@ } return await VerifyAsync(args[1], args[2]); + case "gen-invocation": + if (args.Length != 2) + { + Console.Error.WriteLine("Usage: ZcapLd.Interop gen-invocation "); + return 2; + } + return await GenerateInvocationAsync(args[1]); + + case "verify-invocation": + if (args.Length != 3) + { + Console.Error.WriteLine("Usage: ZcapLd.Interop verify-invocation "); + return 2; + } + return await VerifyInvocationAsync(args[1], args[2]); + default: Console.Error.WriteLine($"Unknown command: {args[0]}"); return 2; @@ -150,6 +167,66 @@ async Task VerifyAsync(string delegatedFile, string rootFile) return 1; } +async Task GenerateInvocationAsync(string vectorsDir) +{ + var provider = new DeterministicDidProvider(); + var rootDid = provider.RegisterDeterministicDidKey(Seed(0x11)); + var delegateDid = provider.RegisterDeterministicDidKey(Seed(0x22)); + var signing = new SigningService(provider, provider); + var caps = new CapabilityService(signing); + + var root = await caps.CreateRootCapabilityAsync(rootDid, Target); + provider.RegisterRoot(root); + + // Root invocation: capability = root id string; signed by the ROOT controller. + var rootInvocation = await signing.SignCapabilityInvocationAsync( + InvocationCapability.FromId(root.Id), "read", Target, rootDid); + + // Delegated invocation: capability = full embedded delegated zcap; signed by the DELEGATE. + var delegated = await caps.DelegateCapabilityAsync(root, delegateDid, new[] { "read" }, DateTime.UtcNow.AddDays(30)); + var delegatedInvocation = await signing.SignCapabilityInvocationAsync( + InvocationCapability.FromCapability(delegated), "read", Target, delegateDid); + + WriteNode(Path.Combine(vectorsDir, "dotnet-invocation-root.json"), rootInvocation); + WriteJson(Path.Combine(vectorsDir, "dotnet-invocation-root-zcap.json"), root); + WriteNode(Path.Combine(vectorsDir, "dotnet-invocation-delegated.json"), delegatedInvocation); + WriteJson(Path.Combine(vectorsDir, "dotnet-invocation-delegated-root.json"), root); + + Console.WriteLine("GENERATED (DI invocations)"); + Console.WriteLine($" root invocation -> dotnet-invocation-root.json (+ -root-zcap.json)"); + Console.WriteLine($" delegated invocation -> dotnet-invocation-delegated.json (+ -delegated-root.json)"); + return 0; +} + +async Task VerifyInvocationAsync(string securedDocFile, string rootFile) +{ + var secured = ReadNode(securedDocFile); + var root = ReadJson(rootFile); + + var provider = new DeterministicDidProvider(); + provider.RegisterRoot(root); // verifier dereferences the chain's root by id + + var verifier = new VerificationService( + provider, + new CaveatProcessor(), + new RevocationService(new InMemoryRevocationStore()), + new InMemoryNonceStore()); + + // Pass the root explicitly so both a root invocation (capability = root id) and a delegated + // invocation (chain[0] = root id) can resolve it. + var result = await verifier.VerifyCapabilityInvocationDetailedAsync(secured, root, contextProperties: null); + + if (result.IsValid) + { + Console.WriteLine("PASS"); + return 0; + } + + Console.WriteLine("FAIL"); + Console.WriteLine($"{result.Outcome}: {result.Message}"); + return 1; +} + static void WriteJson(string path, Capability capability) { Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); @@ -157,9 +234,22 @@ static void WriteJson(string path, Capability capability) File.WriteAllText(path, json); } +static void WriteNode(string path, JsonObject node) +{ + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(path))!); + File.WriteAllText(path, node.ToJsonString(new JsonSerializerOptions { WriteIndented = true })); +} + static Capability ReadJson(string path) { var json = File.ReadAllText(path); return JsonSerializer.Deserialize(json, ZcapJsonOptions.Default) ?? throw new InvalidOperationException($"Could not deserialize capability from {path}"); } + +static JsonObject ReadNode(string path) +{ + var json = File.ReadAllText(path); + return JsonNode.Parse(json)?.AsObject() + ?? throw new InvalidOperationException($"Could not parse a JSON object from {path}"); +} diff --git a/interop/js/invoke-gen.mjs b/interop/js/invoke-gen.mjs new file mode 100644 index 0000000..b6abc19 --- /dev/null +++ b/interop/js/invoke-gen.mjs @@ -0,0 +1,229 @@ +/*! + * invoke-gen.mjs -- generate Data Integrity `capabilityInvocation` (Path A) + * vectors using the REAL @digitalbazaar/zcap v9 reference library, and write + * them to the shared interop vectors directory. + * + * Produces two flavours of invocation: + * + * 1. ROOT invocation (`js-invocation-root.json`) + * - The secured document is a minimal application document. + * - The proof's `capability` is the ROOT id STRING + * (`urn:zcap:root:${encodeURIComponent(target)}`). + * - Signed by the ROOT controller key (seed 0x01). + * + * 2. DELEGATED invocation (`js-invocation-delegated.json` + its root in + * `js-invocation-delegated-root.json`) + * - First delegate root -> delegate (signed by the root controller), then + * invoke with the proof's `capability` set to the FULL EMBEDDED delegated + * zcap object. + * - Signed by the DELEGATE key (seed 0x02). + * - The matching root is written separately so the verifier can resolve + * the chain (the delegated zcap's `capabilityChain` references the root + * by id only). + * + * Shared parameters (deterministic so the .NET side can match byte-for-byte): + * - root controller key: 32 bytes all 0x01 + * - delegate key: 32 bytes all 0x02 + * - invocationTarget: https://example.com/api/items + * - capabilityAction: read + * - `created`: seconds precision, UTC (no milliseconds) + * + * Run with: node invoke-gen.mjs + */ +import {writeFileSync, mkdirSync} from 'node:fs'; +import {fileURLToPath} from 'node:url'; +import {dirname, join} from 'node:path'; + +import jsigs from 'jsonld-signatures'; +import {CapabilityDelegation, createRootCapability} + from '@digitalbazaar/zcap'; +import {Ed25519Signature2020} + from '@digitalbazaar/ed25519-signature-2020'; + +import { + didKeyFromSeed, + makeDocumentLoader, + signInvocation, + ZCAP_CONTEXT_URL, + ED25519_2020_CONTEXT_URL +} from './lib.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const VECTORS_DIR = join(__dirname, '..', 'vectors'); + +const INVOCATION_TARGET = 'https://example.com/api/items'; +const CAPABILITY_ACTION = 'read'; + +// Deterministic seeds: 0x01 * 32 (root controller), 0x02 * 32 (delegate). +const ROOT_SEED = new Uint8Array(32).fill(0x01); +const DELEGATE_SEED = new Uint8Array(32).fill(0x02); + +/** + * Format a Date as a `YYYY-MM-DDTHH:mm:ssZ` UTC string (seconds precision, no + * milliseconds) -- matches the shape the .NET side emits. + * + * @param {Date} date - The date to format. + * @returns {string} The formatted timestamp. + */ +function toZcapTimestamp(date) { + return date.toISOString().replace(/\.\d{3}Z$/, 'Z'); +} + +// A deterministic application-document id + type so the body hashes the same +// every run (the .NET side must reproduce this exactly). The `type` is an +// absolute IRI -- it does not need to be a context-defined term, it just has to +// make the body expand to a non-empty RDF dataset (see below). +const APP_DOC_ID = 'urn:uuid:00000000-0000-0000-0000-000000000001'; +const APP_DOC_TYPE = 'https://example.com/zcap-interop/vocab#InvocationRequest'; + +/** + * Build the minimal application document the .NET side must reproduce. + * + * The body's `@context` carries the two entries the verifier needs (see + * lib.mjs): + * - the zcap context, so the verifier's `checkProofContext` is satisfied + * (jsigs derives the proof's effective `@context` from the document's), and + * - the ed25519-2020 suite context, so the Ed25519Signature2020 suite's + * `matchProof` accepts the document. + * + * It also carries an `id` (`@id`) and a `type` (`@type`). These are REQUIRED: + * the suite canonicalizes the document with JSON-LD safe mode, which REJECTS a + * body that expands to an empty RDF dataset ("Dropping empty object"). A bare + * `{"@context": [...]}` (or an object with only an `id`) expands to nothing, so + * we add a `type` to emit at least one triple + * (` rdf:type `). The invocation parameters themselves still + * live ENTIRELY in the proof -- the body says nothing about the capability. + * + * @returns {object} A fresh minimal application document. + */ +function makeApplicationDocument() { + return { + '@context': [ZCAP_CONTEXT_URL, ED25519_2020_CONTEXT_URL], + id: APP_DOC_ID, + type: APP_DOC_TYPE + }; +} + +async function main() { + // ----- deterministic identities ----- + const rootController = await didKeyFromSeed(ROOT_SEED); + const delegate = await didKeyFromSeed(DELEGATE_SEED); + + console.log('root controller did:key :', rootController.did); + console.log('root verificationMethod :', rootController.verificationMethod); + console.log('delegate did:key :', delegate.did); + console.log('delegate verificationMethod:', delegate.verificationMethod); + + // pin a single seconds-precision `now` so both vectors are deterministic + const now = new Date(); + const created = toZcapTimestamp(now); + + // ----- root capability (shared by both vectors) ----- + const root = createRootCapability({ + controller: rootController.did, + invocationTarget: INVOCATION_TARGET + }); + + mkdirSync(VECTORS_DIR, {recursive: true}); + + // ========================================================================= + // 1. ROOT invocation -- proof.capability is the root id STRING. + // ========================================================================= + const rootInvocation = await signInvocation({ + applicationDocument: makeApplicationDocument(), + // ROOT capabilities MUST be referenced as a string (their id) + capability: root.id, + capabilityAction: CAPABILITY_ACTION, + invocationTarget: INVOCATION_TARGET, + key: rootController.key, + created, + // documentLoader must serve the root by id for verification later + roots: [root] + }); + + const rootInvocationPath = join(VECTORS_DIR, 'js-invocation-root.json'); + // The root invocation's `proof.capability` is the root id STRING only; the + // verifier dereferences that id via the documentLoader. So the actual ROOT + // CAPABILITY is written to its own companion file -- pass it as the + // argument to invoke-verify.mjs (the secured document does not embed it). + const rootZcapPath = join(VECTORS_DIR, 'js-invocation-root-zcap.json'); + writeFileSync( + rootInvocationPath, JSON.stringify(rootInvocation, null, 2) + '\n'); + writeFileSync(rootZcapPath, JSON.stringify(root, null, 2) + '\n'); + console.log('\nwrote', rootInvocationPath); + console.log('wrote', rootZcapPath); + console.log(' proof.capability :', rootInvocation.proof.capability); + console.log(' proof.capabilityAction :', + rootInvocation.proof.capabilityAction); + console.log(' proof.invocationTarget :', + rootInvocation.proof.invocationTarget); + + // ========================================================================= + // 2. DELEGATED invocation -- first delegate root -> delegate, then invoke + // with proof.capability set to the FULL EMBEDDED delegated zcap. + // ========================================================================= + const expires = toZcapTimestamp( + new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)); + + // build the (unsigned) delegated capability: parent is the root, so the + // capabilityChain is exactly [root.id] and the delegation proof is signed by + // the ROOT controller (the party authorized to delegate the root). + const delegatedDoc = { + '@context': [ZCAP_CONTEXT_URL, ED25519_2020_CONTEXT_URL], + id: `urn:uuid:${crypto.randomUUID()}`, + controller: delegate.did, + invocationTarget: INVOCATION_TARGET, + allowedAction: [CAPABILITY_ACTION], + expires, + parentCapability: root.id + }; + + const delegationLoader = makeDocumentLoader({roots: [root]}); + const delegated = await jsigs.sign(delegatedDoc, { + suite: new Ed25519Signature2020({ + key: rootController.key, + date: created + }), + purpose: new CapabilityDelegation({parentCapability: root.id}), + documentLoader: delegationLoader + }); + + // now invoke the delegated zcap: proof.capability is the FULL embedded object, + // signed by the DELEGATE key (the delegated zcap's controller). + const delegatedInvocation = await signInvocation({ + applicationDocument: makeApplicationDocument(), + // DELEGATED capabilities MUST be embedded as the full object + capability: delegated, + capabilityAction: CAPABILITY_ACTION, + invocationTarget: INVOCATION_TARGET, + key: delegate.key, + created, + // documentLoader must serve the root by id to dereference the chain + roots: [root] + }); + + const delegatedInvocationPath = + join(VECTORS_DIR, 'js-invocation-delegated.json'); + const delegatedRootPath = + join(VECTORS_DIR, 'js-invocation-delegated-root.json'); + writeFileSync( + delegatedInvocationPath, + JSON.stringify(delegatedInvocation, null, 2) + '\n'); + writeFileSync( + delegatedRootPath, JSON.stringify(root, null, 2) + '\n'); + + console.log('\nwrote', delegatedInvocationPath); + console.log('wrote', delegatedRootPath); + console.log(' proof.capability.id :', + delegatedInvocation.proof.capability.id); + console.log(' proof.capability.proof.capabilityChain:', + JSON.stringify( + delegatedInvocation.proof.capability.proof.capabilityChain)); + console.log(' proof.verificationMethod :', + delegatedInvocation.proof.verificationMethod); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/interop/js/invoke-verify.mjs b/interop/js/invoke-verify.mjs new file mode 100644 index 0000000..eac969a --- /dev/null +++ b/interop/js/invoke-verify.mjs @@ -0,0 +1,105 @@ +/*! + * invoke-verify.mjs [expectedAction] [expectedTarget] + * + * Loads a secured application document (carrying a `capabilityInvocation` proof) + * plus the root capability it ultimately authorizes, and verifies the + * invocation using the REAL @digitalbazaar/zcap v9 reference library. Prints + * PASS / FAIL. + * + * - exit code 0 => verified (prints PASS) + * - exit code 1 => not verified or error (prints FAIL + full error chain) + * + * The root id used as `expectedRootCapability` is taken from the root file's + * `id`. `expectedAction` defaults to "read" and `expectedTarget` defaults to + * "https://example.com/api/items" (the interop defaults) if not supplied. + * + * Examples: + * # root invocation (root file IS the secured doc's root) + * node invoke-verify.mjs ../vectors/js-invocation-root.json \ + * ../vectors/js-invocation-root.json read https://example.com/api/items + * + * # delegated invocation (root is a separate file) + * node invoke-verify.mjs ../vectors/js-invocation-delegated.json \ + * ../vectors/js-invocation-delegated-root.json + */ +import {readFileSync} from 'node:fs'; + +import {verifyInvocation} from './lib.mjs'; + +const DEFAULT_ACTION = 'read'; +const DEFAULT_TARGET = 'https://example.com/api/items'; + +/** + * Recursively flatten the nested error chain jsonld-signatures produces. + * jsigs wraps failures and nests sub-errors under `error.errors` (and + * sometimes `error.cause`), so we walk both to surface the real reason. + * + * @param {Error} error - The top-level error from the verify result. + * @param {number} [depth] - Current recursion depth (guards against cycles). + * @returns {object} A plain, JSON-serializable error tree. + */ +function flattenError(error, depth = 0) { + if(!error || depth > 10) { + return error ? String(error) : null; + } + const out = { + name: error.name, + message: error.message + }; + if(error.details !== undefined) { + out.details = error.details; + } + // jsigs nests an array of sub-errors here + if(Array.isArray(error.errors) && error.errors.length > 0) { + out.errors = error.errors.map(e => flattenError(e, depth + 1)); + } + // standard Error cause chaining + if(error.cause) { + out.cause = flattenError(error.cause, depth + 1); + } + return out; +} + +function loadJson(path) { + return JSON.parse(readFileSync(path, 'utf8')); +} + +async function main() { + const [, , securedDocFile, rootFile, actionArg, targetArg] = process.argv; + if(!securedDocFile || !rootFile) { + console.error( + 'Usage: node invoke-verify.mjs ' + + '[expectedAction] [expectedTarget]'); + process.exit(1); + } + + const securedDocument = loadJson(securedDocFile); + const root = loadJson(rootFile); + + const expectedAction = actionArg || DEFAULT_ACTION; + const expectedTarget = targetArg || DEFAULT_TARGET; + + const result = await verifyInvocation({ + securedDocument, + expectedAction, + expectedTarget, + // the chain's root MUST match this id; the documentLoader serves it by id + expectedRootCapability: root.id, + root + }); + + if(result.verified) { + console.log('PASS'); + process.exit(0); + } + + console.log('FAIL'); + console.log(JSON.stringify(flattenError(result.error), null, 2)); + process.exit(1); +} + +main().catch(err => { + console.log('FAIL'); + console.log(JSON.stringify(flattenError(err), null, 2)); + process.exit(1); +}); diff --git a/interop/js/lib.mjs b/interop/js/lib.mjs index 15bf73c..fa4ef8e 100644 --- a/interop/js/lib.mjs +++ b/interop/js/lib.mjs @@ -16,7 +16,7 @@ import {createRequire} from 'node:module'; import jsigs from 'jsonld-signatures'; -import {CapabilityDelegation, constants as zcapConstants} +import {CapabilityDelegation, CapabilityInvocation, constants as zcapConstants} from '@digitalbazaar/zcap'; import {Ed25519Signature2020} from '@digitalbazaar/ed25519-signature-2020'; @@ -169,3 +169,141 @@ export async function verifyDelegation({delegated, root}) { documentLoader }); } + +// --------------------------------------------------------------------------- +// Data Integrity `capabilityInvocation` (Path A). +// --------------------------------------------------------------------------- + +/* + * Wire-shape notes (the .NET side matches these byte-for-byte): + * + * - The SECURED document is an *application document*: an arbitrary JSON-LD + * object that carries a `capabilityInvocation` proof. The invocation + * parameters (`capability` / `capabilityAction` / `invocationTarget`) live + * ONLY inside the proof object -- they are NOT copied into the signed + * document body (CapabilityInvocation.update() stamps them onto `proof`). + * + * - The document body's `@context` MUST contain BOTH: + * 1. the ZCAP context (`https://w3id.org/zcap/v1`) -- the verifier's + * `utils.checkProofContext` requires it to be present in the proof's + * effective `@context`, and jsigs derives the proof's `@context` from + * the *document's* `@context` at verify time (ProofSet `_getProofs`); + * the signed proof object therefore carries NO `@context` of its own. + * 2. the ed25519-2020 suite context + * (`https://w3id.org/security/suites/ed25519-2020/v1`) -- the + * Ed25519Signature2020 suite's `matchProof` refuses any document that + * does not include the suite context. + * So the minimal application-document shape we use is exactly: + * {"@context": [ZCAP_CONTEXT_URL, ED25519_2020_CONTEXT_URL]} + * (any additional, context-defined fields would also be signed; we keep it + * minimal so the .NET side has the smallest possible body to reproduce). + * + * - Signing input (RDFC-1.0, via the suite's LinkedDataSignature base): + * verifyData = SHA256(canonicalize(proofOptions)) + * || SHA256(canonicalize(applicationDocument)) + * i.e. the PROOF-OPTIONS hash comes FIRST, the document hash SECOND. The + * proof-options object is canonicalized with the document's `@context` + * spliced in front of it (so BOTH halves canonicalize under the same + * `@context`), with `proofValue` stripped. Each half is canonicalized with + * RDFC-1.0 (jsonld -> RDF dataset -> URDNA2015), NOT JCS. + */ + +/** + * Create a Data Integrity `capabilityInvocation` proof over an application + * document, using the REAL @digitalbazaar/zcap CapabilityInvocation purpose. + * + * Per CapabilityInvocation's constructor, the create-proof params are + * `{capability, capabilityAction, invocationTarget}` and they MUST NOT be mixed + * with any verify-proof param (`expectedAction` / `expectedTarget` / + * `expectedRootCapability` / `suite` / `controller` / `date` / + * `inspectCapabilityChain`) -- doing so throws + * "Parameters for both creating and verifying a proof must not be provided + * together." `capability` MUST be a string for a ROOT invocation (the root id) + * and a full embedded object for a DELEGATED invocation. + * + * @param {object} options - Options. + * @param {object} options.applicationDocument - The JSON-LD document to secure + * (its `@context` must include the zcap + ed25519-2020 contexts). + * @param {string|object} options.capability - Root id string (root invocation) + * or the full embedded delegated zcap object (delegated invocation). + * @param {string} options.capabilityAction - The action being invoked, e.g. + * `"read"`. + * @param {string} options.invocationTarget - The absolute-URI target. + * @param {object} options.key - The signing key (Ed25519VerificationKey2020 + * with private material) belonging to the controller authorized to invoke. + * @param {string|Date} [options.created] - Seconds-precision `created` to pin + * onto the proof (passed to the suite as `date`). + * @param {object[]} [options.roots] - Root capabilities the documentLoader must + * serve by id (the root for a delegated invocation's embedded chain). + * @returns {Promise} The secured document (application document + a + * top-level `proof` whose `proofPurpose` is `capabilityInvocation`). + */ +export async function signInvocation({ + applicationDocument, capability, capabilityAction, invocationTarget, key, + created, roots = [] +}) { + const documentLoader = makeDocumentLoader({roots}); + + return jsigs.sign(applicationDocument, { + suite: new Ed25519Signature2020({key, date: created}), + // create-proof params ONLY (mixing in any verify param throws) + purpose: new CapabilityInvocation({ + capability, + capabilityAction, + invocationTarget + }), + documentLoader + }); +} + +/** + * Verify a Data Integrity `capabilityInvocation` proof on a secured + * application document. + * + * Per CapabilityInvocation's constructor, the verify-proof params used here are + * `{expectedTarget, expectedAction, expectedRootCapability, suite}` -- a + * DISJOINT set from the create params. `expectedAction` (string) and + * `expectedTarget` (string|array) are required and validated; the proof's + * `capabilityAction`/`invocationTarget` must match them exactly (no target + * attenuation here). `expectedRootCapability` (validated by the base class, + * string|array of absolute URIs) is the root id the dereferenced chain's root + * MUST equal -- the documentLoader serves that root by id. The `suite` is used + * to verify each delegation proof along the chain. + * + * For a ROOT invocation, `proof.capability` is the root id string; the chain is + * just `[root]`. For a DELEGATED invocation, `proof.capability` is the full + * embedded delegated zcap; the verifier walks its `capabilityChain` (the + * embedded ancestors + the root referenced by id), so the documentLoader must + * serve the root by id and resolve every did:key controller in the chain. + * + * @param {object} options - Options. + * @param {object} options.securedDocument - The application document with its + * `capabilityInvocation` proof. + * @param {string} options.expectedAction - The action the verifier requires. + * @param {string|string[]} options.expectedTarget - The absolute-URI target(s) + * the verifier requires. + * @param {string|string[]} options.expectedRootCapability - The root id(s) the + * chain's root must match. + * @param {object} options.root - The root capability JSON, served by the + * documentLoader by its `id`. + * @returns {Promise} The full jsigs.verify() result + * (`{verified, results, error?}`). + */ +export async function verifyInvocation({ + securedDocument, expectedAction, expectedTarget, expectedRootCapability, root +}) { + const documentLoader = makeDocumentLoader({roots: [root]}); + const suite = new Ed25519Signature2020(); + + return jsigs.verify(securedDocument, { + suite, + // verify-proof params ONLY (mixing in any create param throws) + purpose: new CapabilityInvocation({ + expectedTarget, + expectedAction, + expectedRootCapability, + suite + }), + documentLoader + }); +} diff --git a/interop/run-interop.sh b/interop/run-interop.sh index 7b03c25..c85eeda 100755 --- a/interop/run-interop.sh +++ b/interop/run-interop.sh @@ -1,17 +1,17 @@ #!/usr/bin/env bash # -# Live cross-stack RDFC interop harness for ZCAP-LD capability DELEGATION proofs. +# Live cross-stack RDFC interop harness for ZCAP-LD: capability DELEGATION + Data +# Integrity INVOCATION proofs. # # Proves, end-to-end and at runtime, that zcap-dotnet's RDFC-1.0 path is wire- -# compatible with the @digitalbazaar/zcap v9 reference implementation: +# compatible with the @digitalbazaar/zcap v9 reference implementation, both ways: # -# 1. OUTBOUND dotnet signs (RDFC) -> @digitalbazaar/zcap verifies (must PASS) -# 2. INBOUND @digitalbazaar/zcap signs -> dotnet verifies (RDFC) (must PASS) -# 3. NEG out tamper dotnet zcap -> @digitalbazaar/zcap rejects (must FAIL) -# 4. NEG in tamper db zcap -> dotnet rejects (must FAIL) +# delegation (1-6): dotnet<->db verify single + 2-level chains, + tamper rejects. +# invocation (7-12): dotnet<->db verify root + delegated DI capabilityInvocation, +# + tamper rejects. # -# Scope: delegation proofs only (the canonicalization interop path). Invocation -# interop is a separate envelope concern, out of scope. See +# Scope: delegation + DI invocation. HTTP-Signature invocations (the deployed ezcap +# transport, "Path B") are not covered. See docs/ZCAP-LD-ROADMAP.md and # docs/ZCAP-LD-INTEROP-COMPATIBILITY-ANALYSIS.md. # # Usage: interop/run-interop.sh @@ -49,6 +49,7 @@ expect() { dotnet_cli() { dotnet "$DLL" "$@"; } js_verify() { ( cd "$JS" && node verify.mjs "$@" ); } +js_invoke_verify() { ( cd "$JS" && node invoke-verify.mjs "$@" ); } cyan "== Setup ==" echo "-- building .NET harness (Release) --" @@ -63,9 +64,11 @@ cyan "== Generate fresh vectors (both stacks) ==" dotnet_cli gen "$VEC/dotnet-root.json" "$VEC/dotnet-delegated.json" || { red "dotnet gen failed"; exit 1; } dotnet_cli gen-multi "$VEC/dotnet-multi-root.json" "$VEC/dotnet-multi-l1.json" "$VEC/dotnet-multi-l2.json" \ || { red "dotnet gen-multi failed"; exit 1; } +dotnet_cli gen-invocation "$VEC" || { red "dotnet gen-invocation failed"; exit 1; } ( cd "$JS" && node gen.mjs ) >/dev/null || { red "js gen failed"; exit 1; } ( cd "$JS" && node gen-multi.mjs ) >/dev/null || { red "js gen-multi failed"; exit 1; } -echo " wrote single- and multi-level dotnet-*.json and js-*.json under interop/vectors/" +( cd "$JS" && node invoke-gen.mjs ) >/dev/null || { red "js invoke-gen failed"; exit 1; } +echo " wrote delegation + invocation, dotnet-*.json and js-*.json under interop/vectors/" cyan "== 1. OUTBOUND: @digitalbazaar/zcap verifies the dotnet RDFC capability ==" js_verify "$VEC/dotnet-delegated.json" "$VEC/dotnet-root.json" @@ -93,11 +96,41 @@ cyan "== 6. MULTI-LEVEL inbound: zcap-dotnet verifies the db 2-level chain ==" dotnet_cli verify "$VEC/js-multi-l2.json" "$VEC/js-multi-root.json" expect "db -> dotnet (depth-2 chain verifies locally)" PASS $? -rm -f "$VEC/dotnet-delegated.tampered.json" "$VEC/js-delegated.tampered.json" +# ── Invocation (Data Integrity capabilityInvocation, Path A) ── +T=https://example.com/api/items + +cyan "== 7. INVOCATION outbound (root): db verifies the dotnet DI invocation ==" +js_invoke_verify "$VEC/dotnet-invocation-root.json" "$VEC/dotnet-invocation-root-zcap.json" read "$T" +expect "dotnet -> db (root invocation verifies upstream)" PASS $? + +cyan "== 8. INVOCATION inbound (root): zcap-dotnet verifies the db DI invocation ==" +dotnet_cli verify-invocation "$VEC/js-invocation-root.json" "$VEC/js-invocation-root-zcap.json" +expect "db -> dotnet (root invocation verifies locally)" PASS $? + +cyan "== 9. INVOCATION outbound (delegated): db verifies the dotnet DI invocation ==" +js_invoke_verify "$VEC/dotnet-invocation-delegated.json" "$VEC/dotnet-invocation-delegated-root.json" read "$T" +expect "dotnet -> db (delegated invocation verifies upstream)" PASS $? + +cyan "== 10. INVOCATION inbound (delegated): zcap-dotnet verifies the db DI invocation ==" +dotnet_cli verify-invocation "$VEC/js-invocation-delegated.json" "$VEC/js-invocation-delegated-root.json" +expect "db -> dotnet (delegated invocation verifies locally)" PASS $? + +cyan "== 11. INVOCATION negative (outbound): tampered action must be rejected by db ==" +jq '.proof.capabilityAction="write"' "$VEC/dotnet-invocation-root.json" > "$VEC/dotnet-invocation-root.tampered.json" +js_invoke_verify "$VEC/dotnet-invocation-root.tampered.json" "$VEC/dotnet-invocation-root-zcap.json" write "$T" +expect "dotnet(tampered invocation) -> db (rejected)" FAIL $? + +cyan "== 12. INVOCATION negative (inbound): tampered action must be rejected by dotnet ==" +jq '.proof.capabilityAction="write"' "$VEC/js-invocation-root.json" > "$VEC/js-invocation-root.tampered.json" +dotnet_cli verify-invocation "$VEC/js-invocation-root.tampered.json" "$VEC/js-invocation-root-zcap.json" +expect "db(tampered invocation) -> dotnet (rejected)" FAIL $? + +rm -f "$VEC/dotnet-delegated.tampered.json" "$VEC/js-delegated.tampered.json" \ + "$VEC/dotnet-invocation-root.tampered.json" "$VEC/js-invocation-root.tampered.json" echo if [[ "$FAIL_COUNT" -eq 0 ]]; then - grn "ALL $PASS_COUNT CHECKS PASSED — RDFC delegation interop with @digitalbazaar/zcap proven end-to-end." + grn "ALL $PASS_COUNT CHECKS PASSED — RDFC delegation + invocation interop with @digitalbazaar/zcap proven end-to-end." exit 0 else red "$FAIL_COUNT check(s) failed, $PASS_COUNT passed." diff --git a/src/ZcapLd.Core/Services/SigningService.cs b/src/ZcapLd.Core/Services/SigningService.cs index 274ed1c..e193b64 100644 --- a/src/ZcapLd.Core/Services/SigningService.cs +++ b/src/ZcapLd.Core/Services/SigningService.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using System.Text.Json; +using System.Text.Json.Nodes; using ZcapLd.Core.Cryptography; using ZcapLd.Core.Models; @@ -174,6 +175,103 @@ public async Task SignRevocationAsync( /// so a revocation proof (purpose capabilityRevocation, carrying a signed reason) is /// byte-disjoint from a normal capabilityInvocation proof. /// + /// + /// Signs an application document with a Data Integrity capabilityInvocation proof, producing + /// a @digitalbazaar/zcap-compatible ("Path A") invocation. Unlike the self-contained + /// envelope (used in-stack and for revocation), the invocation metadata + /// (//) + /// lives ONLY in the proof; the signed document is the application payload. The signing input is + /// SHA-256(RDFC(proofOptions)) || SHA-256(RDFC(applicationDocument)) with the proof options + /// canonicalized under the document's @context — byte-compatible with what + /// @digitalbazaar/zcap verifies. Returns the secured document (the application document with + /// the proof attached). + /// + /// Root zcap id string (root invocation) or the full embedded delegated + /// zcap object (delegated invocation). + /// Optional JSON-LD application payload to invoke against. When + /// , a minimal default request document is generated. A supplied document MUST + /// carry an @context containing https://w3id.org/zcap/v1 and the signing suite context, + /// and MUST expand to a non-empty RDF dataset (e.g. carry an id + an absolute-IRI type). + public Task SignCapabilityInvocationAsync( + InvocationCapability capability, + string capabilityAction, + string invocationTarget, + string signerDid, + JsonObject? applicationDocument = null) + => SignCapabilityInvocationAsync( + capability, capabilityAction, invocationTarget, signerDid, applicationDocument, createdOverride: null); + + /// + /// Determinism overload of + /// + /// that stamps an explicit proof created instant. Not on . + /// + public async Task SignCapabilityInvocationAsync( + InvocationCapability capability, + string capabilityAction, + string invocationTarget, + string signerDid, + JsonObject? applicationDocument, + DateTime? createdOverride) + { + ArgumentNullException.ThrowIfNull(capability); + if (string.IsNullOrEmpty(capabilityAction)) + throw new ArgumentException("Capability action cannot be null or empty", nameof(capabilityAction)); + if (string.IsNullOrEmpty(invocationTarget)) + throw new ArgumentException("Invocation target cannot be null or empty", nameof(invocationTarget)); + if (string.IsNullOrEmpty(signerDid)) + throw new ArgumentException("Signer DID cannot be null or empty", nameof(signerDid)); + + var resolvedKey = await _resolver.ResolvePublicKeyAsync(signerDid); + var suite = ZcapSuiteCatalog.GetByKeyType(resolvedKey.KeyType) + ?? throw new CryptographicException($"No signature suite for key type: {resolvedKey.KeyType}"); + var verificationMethod = await _resolver.GetVerificationMethodAsync(signerDid); + var created = ZcapTimestamps.Format(createdOverride ?? DateTime.UtcNow); + + var document = BuildInvocationRequestDocument(applicationDocument, suite.ContextUrl); + + // Invocation proof: capability/action/target live ONLY here. No capabilityChain (that is a + // delegation-proof field), and no @context (the verifier derives it from the document). + var proof = new Proof + { + Type = suite.ProofType, + Created = created, + ProofPurpose = Proof.CapabilityInvocationPurpose, + VerificationMethod = verificationMethod, + ProofValue = string.Empty, + Capability = capability, + InvocationTarget = invocationTarget, + CapabilityAction = capabilityAction, + }; + + var didSigner = CreateLegacySigner(signerDid, resolvedKey, suite.ProofType); + proof.ProofValue = await _legacyProofCrypto.CreateProofValueAsync(document, proof, didSigner); + ConfirmSignature(document, proof, resolvedKey); + + var secured = document.DeepClone()!.AsObject(); + secured["proof"] = JsonSerializer.SerializeToNode(proof, ZcapJsonOptions.Default); + return secured; + } + + /// + /// Builds the JSON-LD application document a Path-A invocation signs. A supplied document is used + /// as-is (the caller owns its contents). The generated default carries the zcap + suite contexts and + /// an id + absolute-IRI type — both required because the JSON-LD canonicalizer rejects + /// a document that expands to an empty RDF dataset (it would otherwise have no triples to sign). + /// + private static JsonObject BuildInvocationRequestDocument(JsonObject? applicationDocument, string suiteContextUrl) + { + if (applicationDocument is not null) + return applicationDocument.DeepClone()!.AsObject(); + + return new JsonObject + { + ["@context"] = new JsonArray("https://w3id.org/zcap/v1", suiteContextUrl), + ["id"] = $"urn:uuid:{Guid.NewGuid()}", + ["type"] = "https://w3id.org/zcap#CapabilityInvocationRequest", + }; + } + private async Task SignInvocationProofAsync( Invocation invocation, string signerDid, diff --git a/src/ZcapLd.Core/Services/VerificationService.cs b/src/ZcapLd.Core/Services/VerificationService.cs index 71cc11a..dd15da1 100644 --- a/src/ZcapLd.Core/Services/VerificationService.cs +++ b/src/ZcapLd.Core/Services/VerificationService.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using NetCrypto; @@ -503,6 +504,180 @@ public Task VerifyInvocationDetailedAsync( "VerifyInvocation", invocation?.Id); } + // ─── Path A: Data Integrity capabilityInvocation (@digitalbazaar/zcap-compatible) ─────────────── + + /// + /// Verifies a @digitalbazaar/zcap-compatible ("Path A") Data Integrity invocation: an + /// application document carrying a capabilityInvocation proof whose proof object alone holds + /// capability/capabilityAction/invocationTarget (they are NOT duplicated into the + /// signed document body). The signature is verified over the application document + /// (SHA-256(RDFC(proofOptions)) || SHA-256(RDFC(document))); the capability chain, attenuation, + /// caveats, controller authorization, freshness and replay are then enforced exactly as for the + /// self-contained envelope path. For a DELEGATED invocation, supply the root + /// via the rootCapability overload (or register an ). + /// + public async Task VerifyCapabilityInvocationAsync(JsonObject securedDocument) + => (await VerifyCapabilityInvocationDetailedAsync(securedDocument)).IsValid; + + /// + public async Task VerifyCapabilityInvocationAsync(JsonObject securedDocument, Capability rootCapability) + => (await VerifyCapabilityInvocationDetailedAsync(securedDocument, rootCapability)).IsValid; + + /// + public Task VerifyCapabilityInvocationDetailedAsync(JsonObject securedDocument) + => LogDenial(VerifyCapabilityInvocationCoreAsync(securedDocument, explicitRoot: null, contextProperties: null), + "VerifyCapabilityInvocation", TryGetDocId(securedDocument)); + + /// + public Task VerifyCapabilityInvocationDetailedAsync(JsonObject securedDocument, Capability rootCapability) + { + ArgumentNullException.ThrowIfNull(rootCapability); + return LogDenial(VerifyCapabilityInvocationCoreAsync(securedDocument, explicitRoot: rootCapability, contextProperties: null), + "VerifyCapabilityInvocation", TryGetDocId(securedDocument)); + } + + /// + public Task VerifyCapabilityInvocationDetailedAsync( + JsonObject securedDocument, Capability? rootCapability, Dictionary? contextProperties) + => LogDenial(VerifyCapabilityInvocationCoreAsync(securedDocument, rootCapability, contextProperties), + "VerifyCapabilityInvocation", TryGetDocId(securedDocument)); + + private static string? TryGetDocId(JsonObject? doc) + => doc is not null && doc.TryGetPropertyValue("id", out var node) + && node is JsonValue v && v.TryGetValue(out var s) ? s : null; + + private async Task VerifyCapabilityInvocationCoreAsync( + JsonObject securedDocument, Capability? explicitRoot, Dictionary? contextProperties) + { + ArgumentNullException.ThrowIfNull(securedDocument); + try + { + // 1. Split the secured document into the signed application document and its proof. + if (!securedDocument.TryGetPropertyValue("proof", out var proofNode) || proofNode is null) + return VerificationResult.Fail(VerificationOutcome.MalformedCapability, + "Secured invocation document is missing a proof."); + + Proof? proof; + try { proof = proofNode.Deserialize(ZcapJsonOptions.Default); } + catch (JsonException) { proof = null; } + if (proof is null) + return VerificationResult.Fail(VerificationOutcome.MalformedCapability, + "Invocation proof could not be parsed."); + + if (proof.ProofPurpose != Proof.CapabilityInvocationPurpose) + return VerificationResult.Fail(VerificationOutcome.MalformedCapability, + "Proof does not have the capabilityInvocation proofPurpose."); + + var applicationDocument = securedDocument.DeepClone()!.AsObject(); + applicationDocument.Remove("proof"); + + // 2. Freshness (Issue #71): bound replay independently of nonce eviction; reuse the validated + // instant for time-based caveats. + var createdUtc = GetFreshProofCreatedUtc(proof); + if (createdUtc is null) + return VerificationResult.Fail(VerificationOutcome.StaleProof, + "Invocation proof.created is missing, future-dated beyond clock skew, or older than the replay window."); + + // 3. The invocation parameters live ONLY in the proof (Path A). + var proofCapability = proof.Capability; + if (proofCapability is null) + return VerificationResult.Fail(VerificationOutcome.MalformedCapability, + "Invocation proof is missing capability."); + if (string.IsNullOrEmpty(proof.CapabilityAction)) + return VerificationResult.Fail(VerificationOutcome.MalformedCapability, + "Invocation proof is missing capabilityAction."); + if (string.IsNullOrEmpty(proof.InvocationTarget)) + return VerificationResult.Fail(VerificationOutcome.MalformedCapability, + "Invocation proof is missing invocationTarget."); + + // 4. Resolve the invoked capability from the proof's capability shape: a root id string is + // dereferenced to the trusted root; an embedded delegated zcap is used directly (its chain is + // verified in step 5). A delegated invocation supplying only an id string is rejected (Issue #51). + Capability capability; + if (proofCapability.IsRootReference) + { + if (string.IsNullOrEmpty(proofCapability.CapabilityId)) + return VerificationResult.Fail(VerificationOutcome.MalformedCapability, + "Root invocation is missing the root capability id."); + capability = await ResolveRootCapabilityAsync(proofCapability.CapabilityId, explicitRoot); + } + else if (proofCapability.EmbeddedCapability is { } embedded) + { + capability = embedded; + } + else + { + return VerificationResult.Fail(VerificationOutcome.MalformedCapability, + "Delegated invocation must embed the full delegated capability object."); + } + + // 5. Verify the capability chain (delegation proofs, attenuation, @context, expiry, revocation). + var chain = await BuildCapabilityChainAsync(capability, explicitRoot); + var chainResult = await VerifyBuiltChainAsync(chain); + if (!chainResult.IsValid) + return chainResult; + + // 6. invocationTarget must be permitted by the leaf capability's target. + if (!IsValidInvocationTarget(proof.InvocationTarget, capability.InvocationTarget)) + return VerificationResult.Fail(VerificationOutcome.InvalidTarget, + "Invocation target is not permitted by the capability's invocationTarget."); + + // 7. capabilityAction must be allowed (null/empty allowedAction == unrestricted root). + if (capability.AllowedAction is { Length: > 0 } actions && !actions.Contains(proof.CapabilityAction)) + return VerificationResult.Fail(VerificationOutcome.ActionNotAllowed, + $"Action '{proof.CapabilityAction}' is not in the capability's allowedAction."); + + // 8. Verify the signature over the APPLICATION DOCUMENT (Path A signs the application payload, + // not a self-contained invocation envelope). Bind the suite (from proof.Type) to the resolved + // key type (Issue #68) before trusting the signature. + var resolvedKey = await _didResolver.ResolvePublicKeyAsync(proof.VerificationMethod); + var suite = ZcapSuiteCatalog.GetByProofType(proof.Type); + if (suite is null || !KeyTypeMatches(suite, resolvedKey)) + return VerificationResult.Fail(VerificationOutcome.InvalidSignature, + "Invocation proof suite is unknown or does not match the resolved key type."); + if (!_legacyProofCrypto.Verify(applicationDocument, proof, resolvedKey)) + return VerificationResult.Fail(VerificationOutcome.InvalidSignature, + "Invocation proof signature did not verify."); + + // 9. The invoker's verificationMethod must be authorized for capabilityInvocation by the + // invoked capability's controller. + if (!await IsControllerAuthorizedAsync( + proof.VerificationMethod, capability, VerificationRelationship.CapabilityInvocation)) + return VerificationResult.Fail(VerificationOutcome.UnauthorizedController, + "Invoker is not authorized by the capability's controller for capabilityInvocation."); + + // 10. Evaluate all caveats across the chain against the signed invocation time. + var context = new InvocationContext + { + InvocationTime = createdUtc.Value, + RequestedAction = proof.CapabilityAction, + TargetResource = proof.InvocationTarget, + }; + if (contextProperties != null) + foreach (var kvp in contextProperties) + context.Properties[kvp.Key] = kvp.Value; + if (!await _caveatProcessor.EvaluateCapabilityChainCaveatsAsync(chain.ToArray(), context)) + return VerificationResult.Fail(VerificationOutcome.CaveatFailed, + "A caveat in the capability chain was not satisfied."); + + // 11. Replay protection keyed on the application document id (the spec's invocation id / nonce). + var nonce = TryGetDocId(securedDocument); + if (string.IsNullOrEmpty(nonce)) + return VerificationResult.Fail(VerificationOutcome.MalformedCapability, + "Secured invocation document is missing an id (required as the replay nonce)."); + if (await _nonceStore.TryMarkAsUsedAsync(nonce, DateTime.UtcNow.Add(_nonceWindow))) + return VerificationResult.Fail(VerificationOutcome.Replayed, + "Invocation nonce has already been used within the replay window."); + + return VerificationResult.Valid; + } + catch (Exception ex) + { + LogFailedClosed(ex, "VerifyCapabilityInvocationAsync failed closed"); + return VerificationResult.Fail(VerificationOutcome.CouldNotVerify, ex.Message); + } + } + private async Task VerifyInvocationCoreAsync( Invocation invocation, Capability capability, diff --git a/tasks/todo-117-path-a-invocation.md b/tasks/todo-117-path-a-invocation.md new file mode 100644 index 0000000..95aaca6 --- /dev/null +++ b/tasks/todo-117-path-a-invocation.md @@ -0,0 +1,49 @@ +# Path-A invocation interop (#117) — DI capabilityInvocation + +Branch: `117-path-a-invocation-interop` (stacked on `verify-path-spec-closeouts` / PR #118). +Approach: **ADDITIVE** — a new digitalbazaar-compatible DI invocation mode alongside the existing +self-contained `Invocation` envelope. The envelope stays for in-stack use AND revocation (which +signs an invocation and is zcap-private control-plane — not a db-interop concern). Do NOT rip it out. + +## Design (harness-first; the JS harness defines the byte-exact target) +- db signs an **application document** + attaches a `capabilityInvocation` **proof**; the invocation + metadata (`capability`/`capabilityAction`/`invocationTarget`) lives ONLY in the proof. Signed bytes + = SHA256(canon(proofOptions)) || SHA256(canon(applicationDocument)). +- Production signing input is built by `LegacyProofCrypto` → DataProofs (NOT ProofSigningPayloadBuilder, + which is the test oracle). DataProofs derives proof-options `@context` from the DOCUMENT's `@context`. + So the application document must carry `@context = [zcap/v1, ed25519-2020/v1]` (suite ctx from + `ZcapSuiteCatalog.GetByKeyType(...).ContextUrl`). `LegacyProofCrypto.BuildDocumentElement` already + leaves an existing `@context` as-is. +- `created` precision is NOT a verification issue: db re-canonicalizes the as-presented doc, so any + valid xsd:dateTime verifies. (Second-precision only matters for byte-identical vectors, not interop.) + +## Steps +- [ ] (subagent) Build db-native DI invocation harness in interop/js (invoke-gen/invoke-verify + + lib.mjs); self-test JS→JS PASS + tamper FAIL; REPORT exact wire shape + CapabilityInvocation + verify recipe (expectedTarget/expectedAction/expectedRootCapability). +- [ ] .NET PATH-A SIGN: new `SigningService.SignCapabilityInvocationAsync(appDoc, capability, + capabilityAction, invocationTarget, signerDid, createdOverride?)` → secured document (appDoc + + proof). Match the harness's app-doc shape byte-for-byte. Root = capability id string; delegated = + full embedded zcap. Iterate against `interop/js/invoke-verify.mjs` until PASS. +- [ ] .NET PATH-A VERIFY: `VerifyCapabilityInvocationAsync(securedDocument, ...)` — extract proof, + recompute signature, verify authorization (resolve capability/chain, action allowed, target + match, chain valid, revocation, replay). Test reverse: JS-gen invocation → .NET verify PASS. +- [ ] .NET unit tests (round-trip, tamper, root + delegated). +- [ ] Wire invocation cases into `interop/run-interop.sh` (both directions + tamper). +- [ ] Docs (README/ARCHITECTURE/CHANGELOG), update #117, PR. + +## Acceptance +`interop/run-interop.sh` round-trips a DI invocation BOTH directions (dotnet→db, db→dotnet) + tamper +reject, alongside the existing delegation cases. Existing envelope invocation + revocation untouched. + +## Review (2026-06-19) — DONE ✅ +All steps done. `interop/run-interop.sh` = **12/12** (6 delegation + 6 invocation: root + delegated, +both directions, + 2 tamper negatives). Suite: **460 Core + 33 AspNetCore green**. +- SIGN: `SigningService.SignCapabilityInvocationAsync(...)` → secured `{appDoc, proof}` (metadata only + in proof; appDoc `@context=[zcap/v1, ed25519-2020/v1]` + `id` + absolute-IRI `type`). Reuses + `LegacyProofCrypto` (same RDFC hash-concat as delegation). +- VERIFY: `VerificationService.VerifyCapabilityInvocationAsync(JsonObject[, root])` — signature over the + app doc, then chain/attenuation/caveats/controller/freshness/replay (reuses existing helpers). +- CLI: `gen-invocation` / `verify-invocation`. JS: `invoke-gen.mjs` / `invoke-verify.mjs` / lib.mjs. +- Tests: `DataIntegrityInvocationTests` (root + delegated round-trip, tamper, action-not-allowed). +- Additive: legacy `Invocation` envelope + revocation untouched. Path B (HTTP Sig) deferred → #119. diff --git a/tests/ZcapLd.Core.Tests/Integration/DataIntegrityInvocationTests.cs b/tests/ZcapLd.Core.Tests/Integration/DataIntegrityInvocationTests.cs new file mode 100644 index 0000000..83ae9c0 --- /dev/null +++ b/tests/ZcapLd.Core.Tests/Integration/DataIntegrityInvocationTests.cs @@ -0,0 +1,102 @@ +using System.Text.Json.Nodes; +using FluentAssertions; +using Xunit; +using ZcapLd.Core.Models; +using ZcapLd.Core.Services; +using ZcapLd.Core.Tests.Helpers; + +namespace ZcapLd.Core.Tests.Integration; + +/// +/// In-stack round-trip tests for the @digitalbazaar/zcap-compatible "Path A" Data Integrity +/// invocation: +/// produces a secured application document (proof carries the invocation metadata), and +/// verifies it. The live +/// cross-stack proof against the real @digitalbazaar/zcap is in interop/run-interop.sh. +/// +public class DataIntegrityInvocationTests +{ + private readonly InMemoryDidProvider _did; + private readonly SigningService _signing; + private readonly CapabilityService _caps; + private readonly VerificationService _verifier; + + public DataIntegrityInvocationTests() + { + _did = new InMemoryDidProvider(); + _signing = new SigningService(_did, _did); + _caps = new CapabilityService(_signing); + _verifier = new VerificationService( + _did, new CaveatProcessor(), + new RevocationService(new InMemoryRevocationStore()), + new InMemoryNonceStore()); + } + + [Fact] + public async Task RootInvocation_DataIntegrity_SignAndVerify_Succeeds() + { + const string owner = "did:key:z6MkDiRootOwner"; + const string target = "https://api.example.com/docs"; + _did.GenerateAndRegisterKeyPair(owner); + var root = _did.RegisterRoot(await _caps.CreateRootCapabilityAsync(owner, target)); + + var secured = await _signing.SignCapabilityInvocationAsync( + InvocationCapability.FromId(root.Id), "read", target, owner); + + (await _verifier.VerifyCapabilityInvocationAsync(secured)).Should().BeTrue(); + } + + [Fact] + public async Task DelegatedInvocation_DataIntegrity_SignAndVerify_Succeeds() + { + const string owner = "did:key:z6MkDiDelOwner"; + const string delegateDid = "did:key:z6MkDiDelDelegate"; + const string target = "https://api.example.com/res"; + _did.GenerateAndRegisterKeyPair(owner); + _did.GenerateAndRegisterKeyPair(delegateDid); + var root = _did.RegisterRoot(await _caps.CreateRootCapabilityAsync(owner, target, new[] { "read", "write" })); + var delegated = await _caps.DelegateCapabilityAsync(root, delegateDid, new[] { "read" }, DateTime.UtcNow.AddDays(7)); + + var secured = await _signing.SignCapabilityInvocationAsync( + InvocationCapability.FromCapability(delegated), "read", target, delegateDid); + + (await _verifier.VerifyCapabilityInvocationAsync(secured)).Should().BeTrue(); + } + + [Fact] + public async Task DataIntegrityInvocation_TamperedSignedAction_Rejected() + { + const string owner = "did:key:z6MkDiTamper"; + const string target = "https://api.example.com/t"; + _did.GenerateAndRegisterKeyPair(owner); + var root = _did.RegisterRoot(await _caps.CreateRootCapabilityAsync(owner, target)); + + var secured = await _signing.SignCapabilityInvocationAsync( + InvocationCapability.FromId(root.Id), "read", target, owner); + + // Flip the signed capabilityAction in the proof after signing → signature must not verify. + secured["proof"]!.AsObject()["capabilityAction"] = "write"; + + (await _verifier.VerifyCapabilityInvocationAsync(secured)).Should().BeFalse( + "altering a signed proof field invalidates the invocation signature"); + } + + [Fact] + public async Task DataIntegrityInvocation_ActionNotInAllowedAction_Rejected() + { + const string owner = "did:key:z6MkDiActOwner"; + const string delegateDid = "did:key:z6MkDiActDelegate"; + const string target = "https://api.example.com/a"; + _did.GenerateAndRegisterKeyPair(owner); + _did.GenerateAndRegisterKeyPair(delegateDid); + var root = _did.RegisterRoot(await _caps.CreateRootCapabilityAsync(owner, target, new[] { "read", "write" })); + var delegated = await _caps.DelegateCapabilityAsync(root, delegateDid, new[] { "read" }, DateTime.UtcNow.AddDays(7)); + + // Validly signed, but invoking "write" which the delegated cap (allowedAction ["read"]) forbids. + var secured = await _signing.SignCapabilityInvocationAsync( + InvocationCapability.FromCapability(delegated), "write", target, delegateDid); + + var result = await _verifier.VerifyCapabilityInvocationDetailedAsync(secured); + result.Outcome.Should().Be(VerificationOutcome.ActionNotAllowed); + } +} From d5d3721a70a9836f7f228cd0c36f48c8083f0173 Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Fri, 19 Jun 2026 08:59:44 -0400 Subject: [PATCH 2/4] Minor version upgrade --- src/ZcapLd.Core/ZcapLd.Core.csproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/ZcapLd.Core/ZcapLd.Core.csproj b/src/ZcapLd.Core/ZcapLd.Core.csproj index 0a957ce..802529f 100644 --- a/src/ZcapLd.Core/ZcapLd.Core.csproj +++ b/src/ZcapLd.Core/ZcapLd.Core.csproj @@ -54,9 +54,9 @@ EcdsaSecp256r1Signature2019 — that sign/verify zcap's 2020-era wire convention (DataProofsDotnet.Legacy). DataProofsDotnet.Core arrives transitively but is listed for clarity. See LegacyProofCrypto.cs. --> - - - + + + From efb31c93ccb7659c00cd48341c4cd30c0d15ac97 Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Fri, 19 Jun 2026 11:29:23 -0400 Subject: [PATCH 3/4] fix(security): close confused-deputy + RDFC-forgery in Path-A invocation verify (adversarial review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An adversarial red-team workflow against the new VerifyCapabilityInvocationCoreAsync (PR #120) found two execution-confirmed flaws; both are fixed here. - CRITICAL (forgery): the verifier did not validate the application document's @context. The proof's invocation terms (capability/capabilityAction/invocationTarget) are zcap/v1 terms and the proof options canonicalize under the document @context, so stripping zcap/v1 makes RDFC-1.0 drop them from the signed N-Quads — leaving them unauthenticated and rewritable after signing. FIX: require the application document @context to be an array beginning with https://w3id.org/zcap/v1 and including the signing suite context BEFORE trusting the signature (mirrors the chain R-CTX-2 check); reject MalformedCapability otherwise. Added AsArrayContextNode helper. - HIGH (confused-deputy): the verifier authorized whatever the proof claimed, with no relying-party expectation gate (unlike @digitalbazaar/zcap, which requires expectedAction/expectedTarget/ expectedRootCapability). FIX: VerifyCapabilityInvocation[Detailed]Async now REQUIRE expectedAction + expectedTargets (and accept optional expectedRootCapabilityIds), failing closed unless the proof matches (action exact, target in the set, resolved root pinned). Removed the no-expectation overloads (the footgun) and added the safe method to IVerificationService. - LOW (nonce/result not bound to the operation) — addressed by the expectation gate. Regression tests (DataIntegrityInvocationTests): stripped-@context forgery -> MalformedCapability; expected action/target/root mismatch -> rejected. Interop CLI/harness updated to pass expectations. 464 Core + 33 AspNetCore tests green; interop/run-interop.sh 12/12. Lessons recorded in tasks/lessons.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 2 +- CHANGELOG.md | 18 ++- README.md | 2 +- interop/README.md | 5 +- interop/ZcapLd.Interop/Program.cs | 20 ++- .../Services/IVerificationService.cs | 21 +++ .../Services/VerificationService.cs | 139 ++++++++++++++---- tasks/lessons.md | 7 + tasks/todo-117-path-a-invocation.md | 14 ++ .../DataIntegrityInvocationTests.cs | 113 ++++++++++++-- 10 files changed, 283 insertions(+), 58 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2763663..1fdf3a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,7 @@ dotnet run --project examples/ZcapLd.Examples # Run con - **Spec-exact `capabilityChain` (#50)**: root referenced by id only (never embedded); first-level chain is exactly `[rootId]`; deeper chains are `[rootId, …ancestorIds, {immediateParent}]`. The verifier rejects non-spec shapes (embedded root, duplicate ids, parent both id+embedded, wrong/missing embedded parent) - **Root resolution (#50)**: spec-exact chains reference the root by id, so the verifier obtains it via an explicit-root verify/revoke overload, else an `IRootCapabilityResolver` (`InMemoryRootCapabilityResolver` for dev; an `IDidResolver` that also implements it is auto-detected), else fails closed. **Breaking (3.0.0)**: wire format no longer embeds the root, so pre-3.0.0 capabilities must be re-delegated - **Invocation `capability` shape (#51)**: `InvocationCapability` (union of root-id **string** | full embedded delegated **zcap object**) backs both `Invocation.Capability` and the invocation `Proof.Capability`, with `InvocationCapabilityJsonConverter` preserving the wire shape. Per ZCAP-LD v0.3 a delegated DI invocation MUST embed the full delegated zcap (`InvocationCapability.FromCapability(delegated)`); a root invocation uses the id string (implicit `string` conversion). Verification is **strict**: a delegated invocation supplying only an id string is rejected. Revocation requests still reference the capability by id string. **Breaking**: `Invocation.Capability` is no longer `string`; `Proof.Capability` is no longer `object?` -- **DI invocation interop — Path A (#117)**: in addition to the self-contained `Invocation` envelope (above; kept for in-stack use + revocation), there is an **additive** `@digitalbazaar/zcap`-compatible mode. `SigningService.SignCapabilityInvocationAsync(capability, action, target, signerDid, appDoc?)` signs an **application document** (default carries `@context=[zcap/v1, ed25519-2020/v1]` + an `id` + an absolute-IRI `type` — both required so the doc expands to a non-empty RDF dataset under JSON-LD safe mode) with a `capabilityInvocation` proof whose proof object ALONE holds `capability`/`capabilityAction`/`invocationTarget` (no envelope, no `capabilityChain`). Signed input = `SHA-256(RDFC(proofOptions)) || SHA-256(RDFC(document))` via `LegacyProofCrypto` (proof-options `@context` spliced from the document by DataProofs). `VerificationService.VerifyCapabilityInvocationAsync(JsonObject securedDocument[, root])` verifies the signature over the application doc, then reuses the chain/attenuation/caveat/controller/freshness/replay checks. Proven live both directions (root + delegated) by `interop/run-interop.sh` checks 7–12. HTTP-Signature invocations (ezcap, "Path B") are deferred (#119). +- **DI invocation interop — Path A (#117)**: in addition to the self-contained `Invocation` envelope (above; kept for in-stack use + revocation), there is an **additive** `@digitalbazaar/zcap`-compatible mode. `SigningService.SignCapabilityInvocationAsync(capability, action, target, signerDid, appDoc?)` signs an **application document** (default carries `@context=[zcap/v1, ed25519-2020/v1]` + an `id` + an absolute-IRI `type` — both required so the doc expands to a non-empty RDF dataset under JSON-LD safe mode) with a `capabilityInvocation` proof whose proof object ALONE holds `capability`/`capabilityAction`/`invocationTarget` (no envelope, no `capabilityChain`). Signed input = `SHA-256(RDFC(proofOptions)) || SHA-256(RDFC(document))` via `LegacyProofCrypto` (proof-options `@context` spliced from the document by DataProofs). `VerificationService.VerifyCapabilityInvocationAsync(JsonObject securedDocument, string expectedAction, IReadOnlyCollection expectedTargets[, root])` (+ `…DetailedAsync` with optional `expectedRootCapabilityIds`) verifies the signature over the application doc, then reuses the chain/attenuation/caveat/controller/freshness/replay checks. **Two security gates an adversarial review added (both required for soundness):** (1) the verifier validates the **application document `@context`** (array, `[0]==zcap/v1`, includes the suite context) BEFORE trusting the signature — otherwise RDFC drops the proof's invocation terms (`capability`/`capabilityAction`/`invocationTarget`, which are zcap/v1 terms) from the signed N-Quads and an attacker could rewrite them (forgery); (2) the relying party MUST supply `expectedAction`/`expectedTargets` (and may pin `expectedRootCapabilityIds`) and the verifier fails closed unless the proof matches — mirrors `@digitalbazaar/zcap`'s required `expectedAction`/`expectedTarget`/`expectedRootCapability` and prevents a confused-deputy replay of a valid invocation over a different capability. There is **no no-expectation overload** (it was the footgun). Proven live both directions (root + delegated) by `interop/run-interop.sh` checks 7–12. HTTP-Signature invocations (ezcap, "Path B") are deferred (#119). - **Revocation**: `IRevocationService` / `IRevocationStore` abstractions; `InMemoryRevocationStore` for dev; ASP.NET endpoints via `ZcapLd.AspNetCore` - **Replay protection**: `INonceStore` interface; `InMemoryNonceStore` (default), `NullNonceStore` (opt-out) - **Proof `created` freshness (#71)**: both the invocation (`VerifyInvocationCoreAsync`) and signed-revocation (`RevokeCapabilityInternalAsync`) paths reject a proof whose signed `created` is missing/unparseable, future-dated beyond a configurable clock skew (`_freshnessClockSkew`, default `DefaultFreshnessClockSkew` = 1 min, set via the `freshnessClockSkew` ctor param), or older than `_nonceWindow` — via the shared `GetFreshProofCreatedUtc` helper (staleness bound reuses the nonce window, so anything still evictable is already too stale; widening `nonceWindow` widens both replay window and acceptable signing age). Bounds replay independently of nonce-store eviction (and even under `NullNonceStore`). The invocation path feeds the validated `created` into `InvocationContext.InvocationTime` (caveats evaluate against signed time). Detailed reason: `VerificationOutcome.StaleProof`. Test seam: determinism overloads `SignInvocationAsync(.., createdOverride)` / `SignRevocationAsync(.., createdOverride)` on the concrete `SigningService` (not on `ISigningService`). **Delegation-proof** `created` freshness is a separate concern (durable proofs, no staleness bound) — see #99 below diff --git a/CHANGELOG.md b/CHANGELOG.md index 767ec12..b1e987b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,12 +16,20 @@ Includes everything in the (unreleased) 3.0.0 section below. - **`@digitalbazaar/zcap`-compatible Data Integrity invocations ("Path A", #117).** New `SigningService.SignCapabilityInvocationAsync(...)` produces a secured application document with a `capabilityInvocation` proof whose proof object alone carries `capability`/`capabilityAction`/ - `invocationTarget` (no self-contained envelope), and `VerificationService.VerifyCapabilityInvocationAsync(...)` + `invocationTarget` (no self-contained envelope), and + `VerificationService.VerifyCapabilityInvocationAsync(securedDocument, expectedAction, expectedTargets[, …])` verifies one (signature over the application document, then chain/attenuation/caveats/controller/ - freshness/replay). Round-trips live against the real `@digitalbazaar/zcap` `CapabilityInvocation` - purpose — root **and** delegated, both directions — in `interop/run-interop.sh` (checks 7–12). This is - **additive**: the existing self-contained `Invocation` envelope (used in-stack and for revocation) is - unchanged. HTTP-Signature invocations (the `ezcap` transport, "Path B") remain a follow-up (#119). + freshness/replay). The verifier has **two security gates** (added after an adversarial red-team of the + initial implementation): (a) the relying party MUST declare `expectedAction` + `expectedTargets` (and + may pin `expectedRootCapabilityIds`) — the verifier fails closed unless the proof matches, mirroring + `@digitalbazaar/zcap`'s required `expectedAction`/`expectedTarget`/`expectedRootCapability` and + preventing a confused-deputy replay of an invocation signed over a different capability; (b) the + application document's `@context` is validated (array, `[0]==zcap/v1`, includes the suite context) + before the signature is trusted, so the invocation terms are actually inside the signed RDFC N-Quads + (without it, stripping `zcap/v1` lets an attacker rewrite them — a forgery). Round-trips live against + the real `@digitalbazaar/zcap` `CapabilityInvocation` purpose — root **and** delegated, both + directions — in `interop/run-interop.sh` (checks 7–12). **Additive**: the self-contained `Invocation` + envelope (in-stack + revocation) is unchanged. HTTP-Signature invocations ("Path B") remain a follow-up (#119). ### Breaking Changes diff --git a/README.md b/README.md index 2eef77a..c6ec5e1 100644 --- a/README.md +++ b/README.md @@ -335,4 +335,4 @@ Capability **delegation** interoperates with `@digitalbazaar/zcap` (proven live - **Use `Ed25519Signature2020`.** P-256 (`EcdsaSecp256r1Signature2019`) has no `@digitalbazaar/zcap` counterpart — it ships/tests Ed25519 only and serves no `ecdsa-2019/v1` context, so a P-256 capability will not verify there. - **Custom caveats are not enforced cross-stack.** `@digitalbazaar/zcap` has no generic caveat engine, so a capability whose security depends on `UsageCountCaveat`/`ValidWhileTrueCaveat` verifies there with the caveat **silently un-enforced**. For cross-stack security guarantees, rely only on `expires`/`allowedAction`/`invocationTarget`. -- **Invocation** interop works via Data Integrity `capabilityInvocation` proofs (`SignCapabilityInvocationAsync` / `VerifyCapabilityInvocationAsync`), proven cross-stack for root + delegated. HTTP-Signature invocations (the `ezcap` transport) are not yet supported (tracked in #119). +- **Invocation** interop works via Data Integrity `capabilityInvocation` proofs (`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 (the `ezcap` transport) are not yet supported (tracked in #119). diff --git a/interop/README.md b/interop/README.md index 2dcaa51..0fa0991 100644 --- a/interop/README.md +++ b/interop/README.md @@ -32,7 +32,10 @@ hashes. This harness replaces inference with an actual `@digitalbazaar/zcap` signed application document whose proof alone carries `capability`/`capabilityAction`/ `invocationTarget`. zcap-dotnet's `SignCapabilityInvocationAsync` / `VerifyCapabilityInvocationAsync` round-trip against the real `@digitalbazaar/zcap` `CapabilityInvocation` purpose, root + delegated. - The legacy self-contained `Invocation` envelope remains for in-stack use + revocation. + The verifier requires the relying party to supply `expectedAction` + `expectedTargets` (as the + reference impl does) and validates the application document's `@context` — both are security gates an + adversarial review added (confused-deputy + RDFC term-dropping forgery defenses). The legacy + self-contained `Invocation` envelope remains for in-stack use + revocation. - **Not yet covered:** HTTP-Signature invocations (the deployed `ezcap` transport — Path B). The whole stack is **RDFC-1.0 only** (JCS was removed in 4.0.0); RDFC-1.0 is the interop format. diff --git a/interop/ZcapLd.Interop/Program.cs b/interop/ZcapLd.Interop/Program.cs index 75c8409..a1ffc5c 100644 --- a/interop/ZcapLd.Interop/Program.cs +++ b/interop/ZcapLd.Interop/Program.cs @@ -62,12 +62,15 @@ return await GenerateInvocationAsync(args[1]); case "verify-invocation": - if (args.Length != 3) + if (args.Length is < 3 or > 5) { - Console.Error.WriteLine("Usage: ZcapLd.Interop verify-invocation "); + Console.Error.WriteLine("Usage: ZcapLd.Interop verify-invocation [expectedAction] [expectedTarget]"); return 2; } - return await VerifyInvocationAsync(args[1], args[2]); + return await VerifyInvocationAsync( + args[1], args[2], + args.Length > 3 ? args[3] : "read", + args.Length > 4 ? args[4] : Target); default: Console.Error.WriteLine($"Unknown command: {args[0]}"); @@ -198,7 +201,7 @@ async Task GenerateInvocationAsync(string vectorsDir) return 0; } -async Task VerifyInvocationAsync(string securedDocFile, string rootFile) +async Task VerifyInvocationAsync(string securedDocFile, string rootFile, string expectedAction, string expectedTarget) { var secured = ReadNode(securedDocFile); var root = ReadJson(rootFile); @@ -212,9 +215,12 @@ async Task VerifyInvocationAsync(string securedDocFile, string rootFile) new RevocationService(new InMemoryRevocationStore()), new InMemoryNonceStore()); - // Pass the root explicitly so both a root invocation (capability = root id) and a delegated - // invocation (chain[0] = root id) can resolve it. - var result = await verifier.VerifyCapabilityInvocationDetailedAsync(secured, root, contextProperties: null); + // The relying party declares what it authorizes (expected action/target/root) — required, so a + // valid invocation over a different capability can't be replayed here. Pass the root explicitly so + // both a root invocation (capability = root id) and a delegated invocation (chain[0] = root id) resolve. + var result = await verifier.VerifyCapabilityInvocationDetailedAsync( + secured, expectedAction, new[] { expectedTarget }, + expectedRootCapabilityIds: new[] { root.Id }, rootCapability: root, contextProperties: null); if (result.IsValid) { diff --git a/src/ZcapLd.Core/Services/IVerificationService.cs b/src/ZcapLd.Core/Services/IVerificationService.cs index ba77fbd..b8b3d9e 100644 --- a/src/ZcapLd.Core/Services/IVerificationService.cs +++ b/src/ZcapLd.Core/Services/IVerificationService.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Nodes; using ZcapLd.Core.Models; namespace ZcapLd.Core.Services; @@ -222,4 +223,24 @@ Task VerifyInvocationDetailedAsync( /// The ID of the capability to check /// True if the capability has been revoked Task IsCapabilityRevokedAsync(string capabilityId); + + /// + /// Verifies a @digitalbazaar/zcap-compatible ("Path A") Data Integrity invocation — an + /// application document carrying a capabilityInvocation proof. The relying party MUST declare + /// the action and target(s) it authorizes; the verifier fails closed unless the proof matches them + /// (this binding prevents a confused-deputy replay of an invocation signed over a different + /// capability). See + /// for structured results and root pinning. + /// + Task VerifyCapabilityInvocationAsync( + JsonObject securedDocument, string expectedAction, IReadOnlyCollection expectedTargets); + + /// + Task VerifyCapabilityInvocationDetailedAsync( + JsonObject securedDocument, + string expectedAction, + IReadOnlyCollection expectedTargets, + IReadOnlyCollection? expectedRootCapabilityIds = null, + Capability? rootCapability = null, + Dictionary? contextProperties = null); } diff --git a/src/ZcapLd.Core/Services/VerificationService.cs b/src/ZcapLd.Core/Services/VerificationService.cs index dd15da1..9bfdbd0 100644 --- a/src/ZcapLd.Core/Services/VerificationService.cs +++ b/src/ZcapLd.Core/Services/VerificationService.cs @@ -509,45 +509,92 @@ public Task VerifyInvocationDetailedAsync( /// /// Verifies a @digitalbazaar/zcap-compatible ("Path A") Data Integrity invocation: an /// application document carrying a capabilityInvocation proof whose proof object alone holds - /// capability/capabilityAction/invocationTarget (they are NOT duplicated into the - /// signed document body). The signature is verified over the application document - /// (SHA-256(RDFC(proofOptions)) || SHA-256(RDFC(document))); the capability chain, attenuation, - /// caveats, controller authorization, freshness and replay are then enforced exactly as for the - /// self-contained envelope path. For a DELEGATED invocation, supply the root - /// via the rootCapability overload (or register an ). + /// capability/capabilityAction/invocationTarget. The signature is verified over + /// the application document (SHA-256(RDFC(proofOptions)) || SHA-256(RDFC(document))); the + /// capability chain, attenuation, caveats, controller authorization, freshness and replay are then + /// enforced as for the self-contained envelope path. + /// + /// SECURITY: the relying party MUST declare what it is willing to authorize via + /// and (and optionally + /// expectedRootCapabilityIds) — exactly as the @digitalbazaar/zcap reference requires. + /// Without this binding, a valid invocation the attacker signed over a DIFFERENT capability it holds + /// could be replayed against this endpoint (confused deputy). The verifier also requires the + /// application document's @context to bind the invocation terms (see the core). + /// /// - public async Task VerifyCapabilityInvocationAsync(JsonObject securedDocument) - => (await VerifyCapabilityInvocationDetailedAsync(securedDocument)).IsValid; - - /// - public async Task VerifyCapabilityInvocationAsync(JsonObject securedDocument, Capability rootCapability) - => (await VerifyCapabilityInvocationDetailedAsync(securedDocument, rootCapability)).IsValid; - - /// - public Task VerifyCapabilityInvocationDetailedAsync(JsonObject securedDocument) - => LogDenial(VerifyCapabilityInvocationCoreAsync(securedDocument, explicitRoot: null, contextProperties: null), - "VerifyCapabilityInvocation", TryGetDocId(securedDocument)); - - /// - public Task VerifyCapabilityInvocationDetailedAsync(JsonObject securedDocument, Capability rootCapability) + /// The action the relying party authorizes; must equal the proof's + /// capabilityAction exactly. + /// The acceptable invocation target(s); the proof's + /// invocationTarget must be one of them (exact match). + public async Task VerifyCapabilityInvocationAsync( + JsonObject securedDocument, string expectedAction, IReadOnlyCollection expectedTargets) + => (await VerifyCapabilityInvocationDetailedAsync(securedDocument, expectedAction, expectedTargets)).IsValid; + + /// + public async Task VerifyCapabilityInvocationAsync( + JsonObject securedDocument, string expectedAction, IReadOnlyCollection expectedTargets, Capability rootCapability) + => (await VerifyCapabilityInvocationDetailedAsync( + securedDocument, expectedAction, expectedTargets, rootCapability: rootCapability)).IsValid; + + /// Single-expected-target convenience overload. + public Task VerifyCapabilityInvocationDetailedAsync( + JsonObject securedDocument, string expectedAction, string expectedTarget, + Capability? rootCapability = null, Dictionary? contextProperties = null) + => VerifyCapabilityInvocationDetailedAsync( + securedDocument, expectedAction, new[] { expectedTarget }, expectedRootCapabilityIds: null, + rootCapability, contextProperties); + + /// + /// Optional: if supplied, the dereferenced chain root id MUST + /// be one of these (defense in depth against a resolver serving an attacker-controlled root). + public Task VerifyCapabilityInvocationDetailedAsync( + JsonObject securedDocument, + string expectedAction, + IReadOnlyCollection expectedTargets, + IReadOnlyCollection? expectedRootCapabilityIds = null, + Capability? rootCapability = null, + Dictionary? contextProperties = null) { - ArgumentNullException.ThrowIfNull(rootCapability); - return LogDenial(VerifyCapabilityInvocationCoreAsync(securedDocument, explicitRoot: rootCapability, contextProperties: null), + ArgumentNullException.ThrowIfNull(securedDocument); + if (string.IsNullOrEmpty(expectedAction)) + throw new ArgumentException("An expected action is required.", nameof(expectedAction)); + ArgumentNullException.ThrowIfNull(expectedTargets); + if (expectedTargets.Count == 0) + throw new ArgumentException("At least one expected target is required.", nameof(expectedTargets)); + + return LogDenial( + VerifyCapabilityInvocationCoreAsync( + securedDocument, expectedAction, expectedTargets, expectedRootCapabilityIds, rootCapability, contextProperties), "VerifyCapabilityInvocation", TryGetDocId(securedDocument)); } - /// - public Task VerifyCapabilityInvocationDetailedAsync( - JsonObject securedDocument, Capability? rootCapability, Dictionary? contextProperties) - => LogDenial(VerifyCapabilityInvocationCoreAsync(securedDocument, rootCapability, contextProperties), - "VerifyCapabilityInvocation", TryGetDocId(securedDocument)); - private static string? TryGetDocId(JsonObject? doc) => doc is not null && doc.TryGetPropertyValue("id", out var node) && node is JsonValue v && v.TryGetValue(out var s) ? s : null; + /// The @context entries when a is an array of strings, else null. + private static IReadOnlyList? AsArrayContextNode(JsonNode? context) + { + if (context is not JsonArray arr) + return null; + var list = new List(arr.Count); + foreach (var item in arr) + { + if (item is JsonValue v && v.TryGetValue(out var s)) + list.Add(s); + else + return null; + } + return list; + } + private async Task VerifyCapabilityInvocationCoreAsync( - JsonObject securedDocument, Capability? explicitRoot, Dictionary? contextProperties) + JsonObject securedDocument, + string expectedAction, + IReadOnlyCollection expectedTargets, + IReadOnlyCollection? expectedRootCapabilityIds, + Capability? explicitRoot, + Dictionary? contextProperties) { ArgumentNullException.ThrowIfNull(securedDocument); try @@ -571,6 +618,21 @@ private async Task VerifyCapabilityInvocationCoreAsync( var applicationDocument = securedDocument.DeepClone()!.AsObject(); applicationDocument.Remove("proof"); + // 1a. SECURITY (forgery defense): the proof's invocation terms (capability/capabilityAction/ + // invocationTarget) are defined ONLY in the zcap/v1 context, and the proof options canonicalize + // under the DOCUMENT's @context. If the document @context omits zcap/v1 (or the suite context), + // RDFC-1.0 drops those terms from the signed N-Quads — they become unauthenticated and an + // attacker could rewrite them after signing without breaking the signature. Require the document + // @context to bind them (mirrors the chain R-CTX-2 check and @digitalbazaar/zcap's + // checkProofContext / suite matchProof). + var docContext = AsArrayContextNode(applicationDocument["@context"]); + var suiteContextUrl = ZcapSuiteCatalog.GetByProofType(proof.Type)?.ContextUrl; + if (docContext is null || docContext.Count == 0 || docContext[0] != ZcapV1Context || + (suiteContextUrl is not null && !docContext.Contains(suiteContextUrl))) + return VerificationResult.Fail(VerificationOutcome.MalformedCapability, + $"Invocation application document @context MUST be an array beginning with \"{ZcapV1Context}\" " + + "and include the signing suite context, so the proof's invocation terms are signature-bound."); + // 2. Freshness (Issue #71): bound replay independently of nonce eviction; reuse the validated // instant for time-based caveats. var createdUtc = GetFreshProofCreatedUtc(proof); @@ -590,6 +652,17 @@ private async Task VerifyCapabilityInvocationCoreAsync( return VerificationResult.Fail(VerificationOutcome.MalformedCapability, "Invocation proof is missing invocationTarget."); + // 3a. SECURITY (confused-deputy defense): the relying party declared what it authorizes via + // expectedAction/expectedTargets. The proof's action/target MUST match exactly — otherwise a + // valid invocation the attacker signed over a DIFFERENT capability it holds could be replayed + // against this endpoint. Mirrors @digitalbazaar/zcap, which requires expectedAction + expectedTarget. + if (!string.Equals(proof.CapabilityAction, expectedAction, StringComparison.Ordinal)) + return VerificationResult.Fail(VerificationOutcome.ActionNotAllowed, + $"Invocation action '{proof.CapabilityAction}' does not match the expected action '{expectedAction}'."); + if (!expectedTargets.Contains(proof.InvocationTarget, StringComparer.Ordinal)) + return VerificationResult.Fail(VerificationOutcome.InvalidTarget, + $"Invocation target '{proof.InvocationTarget}' is not among the relying party's expected targets."); + // 4. Resolve the invoked capability from the proof's capability shape: a root id string is // dereferenced to the trusted root; an embedded delegated zcap is used directly (its chain is // verified in step 5). A delegated invocation supplying only an id string is rejected (Issue #51). @@ -617,6 +690,14 @@ private async Task VerifyCapabilityInvocationCoreAsync( if (!chainResult.IsValid) return chainResult; + // 5a. SECURITY (defense in depth): if the relying party pinned the acceptable root + // capabilities, the dereferenced chain root MUST be one of them — blocks an + // IRootCapabilityResolver that serves an attacker-controlled root over a broad/overlapping target. + if (expectedRootCapabilityIds is { Count: > 0 } && + !expectedRootCapabilityIds.Contains(chain[0].Id, StringComparer.Ordinal)) + return VerificationResult.Fail(VerificationOutcome.MalformedCapability, + $"Resolved root capability '{chain[0].Id}' is not among the relying party's expected roots."); + // 6. invocationTarget must be permitted by the leaf capability's target. if (!IsValidInvocationTarget(proof.InvocationTarget, capability.InvocationTarget)) return VerificationResult.Fail(VerificationOutcome.InvalidTarget, diff --git a/tasks/lessons.md b/tasks/lessons.md index 02767f6..58ebd78 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -1,5 +1,12 @@ # Lessons Learned +## 2026-06-19 — Run adversarial agents on GENERATED CODE before opening a PR (CLAUDE.md §2), not just on analyses +- I shipped the new Path-A invocation verifier (#117/PR #120) with only a functional interop harness + happy-path unit tests + a passive PR reviewer. I had NOT run adversarial/exploit agents against the generated code, which CLAUDE.md §2 explicitly mandates ("Always use adversarial agents to attempt to exploit the code"). The user had to ask "did you run adversarial agents?". +- When prompted, a red-team workflow found a **CRITICAL forgery** (no application-document `@context` validation → RDFC drops the unbound zcap/v1 auth terms → attacker rewrites `capabilityAction`/`invocationTarget` post-signing) and a **HIGH confused-deputy** (no relying-party `expected*` gate) — both execution-confirmed, both would have merged. +- RULE: for any auth-bearing or wire-format code I generate, run the adversarial-exploit workflow (attack dimensions → per-finding refutation) BEFORE opening the PR, and treat the PR as not-ready until it is clean. Functional/interop tests prove it WORKS; they do not prove it can't be ABUSED. +- RDFC-specific red flag: any field the verifier reads for an auth decision MUST be a context-defined term so it lands in the signed N-Quads; otherwise validate the document `@context` so undefined terms can't be silently dropped. (Same root cause as the revocation reason/metadata being informational.) +- Mirror the reference implementation's *required verifier inputs*: `@digitalbazaar/zcap` requires `expectedAction`/`expectedTarget`/`expectedRootCapability`. A verify API that omits them (authorizing whatever the proof claims) is a confused-deputy footgun — don't ship the no-expectation overload. + ## 2026-02-26 10:24:27 - Verify repository identity (path + solution name) before running analysis or commands. - If multiple similarly named repos exist, confirm the target repo explicitly before proceeding. diff --git a/tasks/todo-117-path-a-invocation.md b/tasks/todo-117-path-a-invocation.md index 95aaca6..b314388 100644 --- a/tasks/todo-117-path-a-invocation.md +++ b/tasks/todo-117-path-a-invocation.md @@ -47,3 +47,17 @@ both directions, + 2 tamper negatives). Suite: **460 Core + 33 AspNetCore green* - CLI: `gen-invocation` / `verify-invocation`. JS: `invoke-gen.mjs` / `invoke-verify.mjs` / lib.mjs. - Tests: `DataIntegrityInvocationTests` (root + delegated round-trip, tamper, action-not-allowed). - Additive: legacy `Invocation` envelope + revocation untouched. Path B (HTTP Sig) deferred → #119. + +## Adversarial security review (2026-06-19) — found + fixed 2 real issues +Red-team workflow (16 agents) against the new verifier found, execution-confirmed: +- **CRITICAL (forgery):** no application-document `@context` validation → RDFC drops the unbound + zcap/v1 auth terms → attacker rewrites `capabilityAction`/`invocationTarget` post-signing, still Valid. + FIX: validate doc `@context` (array, `[0]==zcap/v1`, includes suite ctx) before trusting the signature + (mirrors chain R-CTX-2). New `AsArrayContextNode` helper. +- **HIGH (confused-deputy):** no relying-party `expected*` gate. FIX: `VerifyCapabilityInvocation*` now + REQUIRE `expectedAction` + `expectedTargets` (optional `expectedRootCapabilityIds`), fail closed on + mismatch; removed the no-expectation overloads; added the safe method to `IVerificationService`. +- **LOW** (nonce/result not bound to action) — addressed by the expected* gate. +- 6 attack classes held (signature binding, chain forgery, controller-auth, replay, etc.). +Regression tests added: stripped-`@context` forgery, expected-target/action/root mismatch (all reject). +Suite 464 Core + 33 AspNetCore green; interop 12/12. diff --git a/tests/ZcapLd.Core.Tests/Integration/DataIntegrityInvocationTests.cs b/tests/ZcapLd.Core.Tests/Integration/DataIntegrityInvocationTests.cs index 83ae9c0..a4d7c9d 100644 --- a/tests/ZcapLd.Core.Tests/Integration/DataIntegrityInvocationTests.cs +++ b/tests/ZcapLd.Core.Tests/Integration/DataIntegrityInvocationTests.cs @@ -8,14 +8,16 @@ namespace ZcapLd.Core.Tests.Integration; /// -/// In-stack round-trip tests for the @digitalbazaar/zcap-compatible "Path A" Data Integrity -/// invocation: -/// produces a secured application document (proof carries the invocation metadata), and -/// verifies it. The live -/// cross-stack proof against the real @digitalbazaar/zcap is in interop/run-interop.sh. +/// In-stack round-trip + adversarial-regression tests for the @digitalbazaar/zcap-compatible "Path A" +/// Data Integrity invocation. The live cross-stack proof against the real @digitalbazaar/zcap is in +/// interop/run-interop.sh; these lock the security gates an adversarial review identified: +/// the application-document @context binding (forgery defense) and the relying-party +/// expected action/target gate (confused-deputy defense). /// public class DataIntegrityInvocationTests { + private const string SuiteContext = "https://w3id.org/security/suites/ed25519-2020/v1"; + private readonly InMemoryDidProvider _did; private readonly SigningService _signing; private readonly CapabilityService _caps; @@ -43,7 +45,7 @@ public async Task RootInvocation_DataIntegrity_SignAndVerify_Succeeds() var secured = await _signing.SignCapabilityInvocationAsync( InvocationCapability.FromId(root.Id), "read", target, owner); - (await _verifier.VerifyCapabilityInvocationAsync(secured)).Should().BeTrue(); + (await _verifier.VerifyCapabilityInvocationAsync(secured, "read", new[] { target })).Should().BeTrue(); } [Fact] @@ -60,11 +62,11 @@ public async Task DelegatedInvocation_DataIntegrity_SignAndVerify_Succeeds() var secured = await _signing.SignCapabilityInvocationAsync( InvocationCapability.FromCapability(delegated), "read", target, delegateDid); - (await _verifier.VerifyCapabilityInvocationAsync(secured)).Should().BeTrue(); + (await _verifier.VerifyCapabilityInvocationAsync(secured, "read", new[] { target })).Should().BeTrue(); } [Fact] - public async Task DataIntegrityInvocation_TamperedSignedAction_Rejected() + public async Task DataIntegrityInvocation_TamperedSignedAction_FailsSignature() { const string owner = "did:key:z6MkDiTamper"; const string target = "https://api.example.com/t"; @@ -73,12 +75,12 @@ public async Task DataIntegrityInvocation_TamperedSignedAction_Rejected() var secured = await _signing.SignCapabilityInvocationAsync( InvocationCapability.FromId(root.Id), "read", target, owner); - - // Flip the signed capabilityAction in the proof after signing → signature must not verify. secured["proof"]!.AsObject()["capabilityAction"] = "write"; - (await _verifier.VerifyCapabilityInvocationAsync(secured)).Should().BeFalse( - "altering a signed proof field invalidates the invocation signature"); + // Verify with the (tampered) action as expected so the expectation gate passes and the + // SIGNATURE check is what rejects it — the auth terms are bound because @context carries zcap/v1. + var result = await _verifier.VerifyCapabilityInvocationDetailedAsync(secured, "write", target); + result.Outcome.Should().Be(VerificationOutcome.InvalidSignature); } [Fact] @@ -92,11 +94,94 @@ public async Task DataIntegrityInvocation_ActionNotInAllowedAction_Rejected() var root = _did.RegisterRoot(await _caps.CreateRootCapabilityAsync(owner, target, new[] { "read", "write" })); var delegated = await _caps.DelegateCapabilityAsync(root, delegateDid, new[] { "read" }, DateTime.UtcNow.AddDays(7)); - // Validly signed, but invoking "write" which the delegated cap (allowedAction ["read"]) forbids. + // Validly signed "write" (and the relying party expects "write"), but the delegated cap only allows "read". var secured = await _signing.SignCapabilityInvocationAsync( InvocationCapability.FromCapability(delegated), "write", target, delegateDid); - var result = await _verifier.VerifyCapabilityInvocationDetailedAsync(secured); + var result = await _verifier.VerifyCapabilityInvocationDetailedAsync(secured, "write", target); result.Outcome.Should().Be(VerificationOutcome.ActionNotAllowed); } + + // ─── Adversarial regressions (would-be exploits the review found) ─── + + [Fact] + public async Task DataIntegrityInvocation_StrippedZcapContext_RejectedAsMalformed() + { + // FORGERY DEFENSE: if the attacker strips zcap/v1 from the application document's @context, RDFC + // drops capabilityAction/invocationTarget/capability from the signed N-Quads (they are zcap/v1 + // terms), leaving them unauthenticated and rewritable. The verifier MUST reject such a document + // before trusting the signature. Here the attacker signs over a stripped-context document and then + // rewrites the (now-unbound) action — without the @context gate this returned Valid. + const string owner = "did:key:z6MkDiStrip"; + const string target = "https://api.example.com/strip"; + _did.GenerateAndRegisterKeyPair(owner); + var root = _did.RegisterRoot(await _caps.CreateRootCapabilityAsync(owner, target)); + + var attackerDoc = new JsonObject + { + ["@context"] = new JsonArray(SuiteContext), // zcap/v1 deliberately STRIPPED + ["id"] = "urn:uuid:attacker-doc", + ["type"] = "https://w3id.org/zcap#CapabilityInvocationRequest", + }; + var secured = await _signing.SignCapabilityInvocationAsync( + InvocationCapability.FromId(root.Id), "read", target, owner, attackerDoc); + secured["proof"]!.AsObject()["capabilityAction"] = "delete-everything"; // unbound rewrite + + var result = await _verifier.VerifyCapabilityInvocationDetailedAsync(secured, "delete-everything", target); + result.Outcome.Should().Be(VerificationOutcome.MalformedCapability, + "an application document whose @context does not bind the invocation terms must be rejected"); + } + + [Fact] + public async Task DataIntegrityInvocation_ExpectedTargetMismatch_Rejected() + { + // CONFUSED-DEPUTY DEFENSE: a valid invocation for one target presented to an endpoint expecting a + // DIFFERENT target must be rejected — the relying party declares the target it authorizes. + const string owner = "did:key:z6MkDiTgt"; + const string target = "https://api.example.com/owned"; + _did.GenerateAndRegisterKeyPair(owner); + var root = _did.RegisterRoot(await _caps.CreateRootCapabilityAsync(owner, target)); + + var secured = await _signing.SignCapabilityInvocationAsync( + InvocationCapability.FromId(root.Id), "read", target, owner); + + var result = await _verifier.VerifyCapabilityInvocationDetailedAsync( + secured, "read", "https://api.example.com/admin"); + result.Outcome.Should().Be(VerificationOutcome.InvalidTarget); + } + + [Fact] + public async Task DataIntegrityInvocation_ExpectedActionMismatch_Rejected() + { + // CONFUSED-DEPUTY DEFENSE: a valid "read" invocation presented where the endpoint authorizes only + // "delete" must be rejected (and vice-versa) — the action is gated by the relying party's expectation. + const string owner = "did:key:z6MkDiAct2"; + const string target = "https://api.example.com/act2"; + _did.GenerateAndRegisterKeyPair(owner); + var root = _did.RegisterRoot(await _caps.CreateRootCapabilityAsync(owner, target)); + + var secured = await _signing.SignCapabilityInvocationAsync( + InvocationCapability.FromId(root.Id), "read", target, owner); + + var result = await _verifier.VerifyCapabilityInvocationDetailedAsync(secured, "delete", target); + result.Outcome.Should().Be(VerificationOutcome.ActionNotAllowed); + } + + [Fact] + public async Task DataIntegrityInvocation_ExpectedRootMismatch_Rejected() + { + // DEFENSE IN DEPTH: when the relying party pins the acceptable root capabilities, a valid + // invocation whose chain root is not among them is rejected. + const string owner = "did:key:z6MkDiRootPin"; + const string target = "https://api.example.com/pin"; + _did.GenerateAndRegisterKeyPair(owner); + var root = _did.RegisterRoot(await _caps.CreateRootCapabilityAsync(owner, target)); + + var secured = await _signing.SignCapabilityInvocationAsync( + InvocationCapability.FromId(root.Id), "read", target, owner); + + var result = await _verifier.VerifyCapabilityInvocationDetailedAsync( + secured, "read", new[] { target }, expectedRootCapabilityIds: new[] { "urn:zcap:root:some-other-root" }); + result.Outcome.Should().Be(VerificationOutcome.MalformedCapability); + } } From a47924d6c1da975660d648bef509c98c6c05b6cb Mon Sep 17 00:00:00 2001 From: Moises E Jaramillo Date: Fri, 19 Jun 2026 12:16:21 -0400 Subject: [PATCH 4/4] =?UTF-8?q?fix(test):=20address=20PR=20#120=20review?= =?UTF-8?q?=20=E2=80=94=20stale=20interop=20assertion=20(CI),=20interface?= =?UTF-8?q?=20overload,=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BLOCKER (cross-stack-interop CI red): InteropHarnessTests asserted "ALL 6 CHECKS PASSED" but the harness now prints "ALL 12 CHECKS PASSED". Assert the substring "CHECKS PASSED" so adding checks never re-breaks it. Renamed the test + class doc to reflect delegation AND invocation coverage. - Added the single-string-target VerifyCapabilityInvocationDetailedAsync convenience overload to IVerificationService (it was on the concrete class only). - CHANGELOG: note the DataProofsDotnet.* 1.0.0 -> 1.0.1 patch bump (from the "Minor version upgrade" commit on this branch). 464 Core + 33 AspNetCore green; interop wrapper test green (12/12). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 2 +- src/ZcapLd.Core/Services/IVerificationService.cs | 9 +++++++++ tests/ZcapLd.Interop.Tests/InteropHarnessTests.cs | 14 ++++++++------ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1e987b..fe7cf2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,7 +39,7 @@ Includes everything in the (unreleased) 3.0.0 section below. - **Root id encoding matches `encodeURIComponent` exactly.** The root capability id is `urn:zcap:root:{encodeURIComponent(invocationTarget)}`; the new `UriEncoding.EncodeUriComponent` un-escapes `! ' ( ) *` that `Uri.EscapeDataString` over-escapes, so for invocation targets containing those characters the id is now byte-identical to what `@digitalbazaar/zcap` derives (previously divergent). Plain http(s) targets were already identical. - **Verify-path `@context` and root-invariant enforcement (W3C MUST closeouts).** The crypto verification paths now enforce spec invariants the create path already checked but `VerificationService` never called: (a) a **root** `@context` MUST be the single string `https://w3id.org/zcap/v1`; (b) a **delegated** `@context` MUST be an array whose first entry is `https://w3id.org/zcap/v1` and which includes the signing suite context; (c) a **root** MUST NOT carry `expires`/`allowedAction`/`caveat` (rejected unconditionally on verify, matching `@digitalbazaar/zcap`, which rejects root `expires`). **Unknown/unmodeled** root fields (`Capability.AdditionalProperties`) are rejected only under the new opt-in `VerificationPolicy.RejectUnknownRootFields` (default `false`): `@digitalbazaar/zcap`'s `checkCapability` *ignores* unknown root fields and `AdditionalProperties` (`[JsonExtensionData]`) exists to round-trip them, so rejecting them by default would be stricter than the reference impl. Conformant capabilities (including everything zcap-dotnet's create path emits) are unaffected; only malformed/non-conformant documents that previously slipped through the crypto verify path are now rejected with `MalformedCapability`. -- **Dependencies / source — crypto + canonicalization delegated to the shared stack.** `NetDid.Core` / `NetDid.Method.Key` move **1.3.1 → 2.0.1** (NetDid 2.0 relocated its cryptographic primitives to **NetCrypto**), `NetCrypto` **1.1.0** is added as a direct reference, the direct `dotNetRdf.Core` reference is replaced by **DataProofsDotnet.Core / .Rdfc / .Legacy 1.0.0** (which own dotNetRDF + the proof cryptosuites transitively), and **NetCid** rises to 1.6.0. Consumers **recompile**: the crypto types that lived under `NetDid.Core.Crypto` (`DefaultCryptoProvider`, `KeyType`, `EcdsaSignatureFormat`, `DefaultKeyGenerator`) are now `NetCrypto.*`. **The proof wire format is unchanged** (see below). +- **Dependencies / source — crypto + canonicalization delegated to the shared stack.** `NetDid.Core` / `NetDid.Method.Key` move **1.3.1 → 2.0.1** (NetDid 2.0 relocated its cryptographic primitives to **NetCrypto**), `NetCrypto` **1.1.0** is added as a direct reference, the direct `dotNetRdf.Core` reference is replaced by **DataProofsDotnet.Core / .Rdfc / .Legacy 1.0.1** (which own dotNetRDF + the proof cryptosuites transitively), and **NetCid** rises to 1.6.0. Consumers **recompile**: the crypto types that lived under `NetDid.Core.Crypto` (`DefaultCryptoProvider`, `KeyType`, `EcdsaSignatureFormat`, `DefaultKeyGenerator`) are now `NetCrypto.*`. **The proof wire format is unchanged** (see below). - **API — the crypto-suite extension surface is removed.** `ICryptoSuite`, `ICryptoSuiteProvider`, `CryptoSuite`, `CryptoSuiteProvider`, `IDocumentCanonicalizerProvider`, `DocumentCanonicalizerProvider`, `SignatureVerifier`, and `AddZcapCryptoSuite()` are all removed. zcap is no longer a crypto-extension point: it supports a fixed set of suites (`Ed25519Signature2020`, `EcdsaSecp256r1Signature2019`) via the internal `ZcapSuiteCatalog`, with sign/verify delegated to DataProofs' legacy cryptosuites. `SigningService` / `VerificationService` drop their suite-provider and canonicalizer-provider constructor parameters; canonicalization is RDFC-1.0 only (see the RDFC-only entry above — the earlier `canonicalizationMethod` / `AddZcapRdfcCanonicalization()` selection was itself removed). New curves are added in NetCrypto + DataProofs and wired into `ZcapSuiteCatalog`, never via a zcap API (see `docs/crypto-suite-extensibility-decision.md`). In-policy under SemVer (a major 4.0.0 bump). #### From the security + compliance scan remediation diff --git a/src/ZcapLd.Core/Services/IVerificationService.cs b/src/ZcapLd.Core/Services/IVerificationService.cs index b8b3d9e..52ef118 100644 --- a/src/ZcapLd.Core/Services/IVerificationService.cs +++ b/src/ZcapLd.Core/Services/IVerificationService.cs @@ -243,4 +243,13 @@ Task VerifyCapabilityInvocationDetailedAsync( IReadOnlyCollection? expectedRootCapabilityIds = null, Capability? rootCapability = null, Dictionary? contextProperties = null); + + /// Single-expected-target convenience overload of + /// . + Task VerifyCapabilityInvocationDetailedAsync( + JsonObject securedDocument, + string expectedAction, + string expectedTarget, + Capability? rootCapability = null, + Dictionary? contextProperties = null); } diff --git a/tests/ZcapLd.Interop.Tests/InteropHarnessTests.cs b/tests/ZcapLd.Interop.Tests/InteropHarnessTests.cs index 9ba2569..5218b26 100644 --- a/tests/ZcapLd.Interop.Tests/InteropHarnessTests.cs +++ b/tests/ZcapLd.Interop.Tests/InteropHarnessTests.cs @@ -6,10 +6,11 @@ namespace ZcapLd.Interop.Tests; /// -/// CI wrapper around interop/run-interop.sh: runs the live cross-stack RDFC delegation -/// interop harness (zcap-dotnet ⇄ the real @digitalbazaar/zcap) and asserts every check -/// passes. The harness itself builds the interop CLI, does npm ci, signs on each stack, and -/// cross-verifies in both directions (single- and multi-level) plus tamper negatives. +/// CI wrapper around interop/run-interop.sh: runs the live cross-stack RDFC interop harness +/// (zcap-dotnet ⇄ the real @digitalbazaar/zcap) for both capability delegation and Data +/// Integrity invocation, and asserts every check passes. The harness itself builds the interop CLI, +/// does npm ci, signs on each stack, and cross-verifies in both directions (single- and +/// multi-level delegation + root/delegated invocation) plus tamper negatives. /// /// Skips (rather than fails) when bash/node/npm/dotnet are not on PATH, so it is a no-op on /// machines without Node. The dedicated ci-interop.yml workflow provides those tools. @@ -22,7 +23,7 @@ public sealed class InteropHarnessTests public InteropHarnessTests(ITestOutputHelper output) => _output = output; [SkippableFact] - public void RunInteropHarness_RdfcDelegation_RoundTripsWithDigitalBazaar() + public void RunInteropHarness_DelegationAndInvocation_RoundTripWithDigitalBazaar() { var repoRoot = LocateRepoRoot(); var script = Path.Combine(repoRoot, "interop", "run-interop.sh"); @@ -38,7 +39,8 @@ public void RunInteropHarness_RdfcDelegation_RoundTripsWithDigitalBazaar() Assert.True( exitCode == 0, $"Interop harness exited with {exitCode}. Output:\n{output}"); - Assert.Contains("ALL 6 CHECKS PASSED", output); + // Substring (not the exact count) so adding checks to run-interop.sh never re-breaks this. + Assert.Contains("CHECKS PASSED", output); } // Walk up from the test assembly location until we find interop/run-interop.sh.