Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.1.0-preview.2] - 2026-06-13

### Added

- **`ICryptosuite.SupportedProofTypes`** — a default interface member (defaults to the single
`DataIntegrityProof` type) by which a suite declares the proof `type`(s) it verifies. Existing
suites are unaffected and continue to be dispatched by `cryptosuite` name.
- **`CryptosuiteRegistry.GetByProofType(string?)`** — resolves a legacy/type-named suite by its
declared proof `type`, backed by a secondary `type → suite` index that excludes the default
Data Integrity type (so the JCS suites stay unambiguous and name-dispatched).

### Fixed

- **Verify pipeline can now dispatch legacy / type-named proofs (issue #4, FR-4).** The verify
path previously dispatched a proof to a cryptosuite only when it carried a `cryptosuite` member
**and** `type == "DataIntegrityProof"`, making it impossible to register a suite that verifies
pre–Data-Integrity Linked-Data-Signature proofs (`Ed25519Signature2020`,
`EcdsaSecp256r1Signature2019`, …) — which name their algorithm by `type` and carry no
`cryptosuite`. The pipeline could therefore emit (via a custom suite) a legacy-shaped proof it
then refused to verify. Dispatch now falls back to the proof `type` when no `cryptosuite` is
present (`cryptosuite`-named suites still always win), closing the create→verify inconsistency
and honoring the FR-4 "registering a new suite requires no pipeline changes" contract. The
library still **creates** only conformant 2022/2019 proofs; this is verification-only and
non-breaking. Suite selection only *routes* — each suite still fully validates
`type`/`cryptosuite`/key/encoding/signature. Regression tests in
`LegacyProofTypeVerificationTests` and `CryptosuiteRegistryTests`.

## [0.1.0-preview.1] - 2026-06-13

First preview release of all five packages (`Core`, `Jose`, `Cose`, `Rdfc`,
Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
<!-- Preview line: the hyphenated SemVer suffix marks every build as a NuGet prerelease.
The first nuget-release ships as a preview (owner decision), not v1.0.0. Release CI
overrides this from the git tag (e.g. v0.1.0-preview.1 -> 0.1.0-preview.1). -->
<DataProofsVersion Condition="'$(DataProofsVersion)' == ''">0.1.0-preview.1</DataProofsVersion>
<DataProofsVersion Condition="'$(DataProofsVersion)' == ''">0.1.0-preview.2</DataProofsVersion>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
<Deterministic>true</Deterministic>
Expand Down
2 changes: 1 addition & 1 deletion dataproofs-prd.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Forbidden everywhere: `net-did` packages, `jose-jwt`, SdJwt.Net (runtime), NSec,

**FR-3 — Verification algorithm, controller authorization, and results.** Verification executes the full spec algorithm, including `@context` validation, `proofPurpose` matching, `domain`/`challenge` checking, and expiry. **Controller authorization (resolver path).** When verification runs through a resolver (FR-7), the algorithm additionally confirms that the proof's `verificationMethod` is *authorized* for the asserted `proofPurpose`: the resolved controller document must associate that method with the verification relationship corresponding to the purpose (`assertionMethod` proofs require the method under `assertionMethod`, `authentication` under `authentication`, ZCAP invocation/delegation under `capabilityInvocation`/`capabilityDelegation`), and the named controller must actually control the method. A method whose signature checks out but that is **not** listed under the required relationship fails verification — this is a distinct failure from a `proofPurpose` *field* mismatch, and the two are tested separately (AC-1). Verification returns a structured result (verified flag + problem details aligned with the processing-error codes defined by VC Data Integrity — `PROOF_VERIFICATION_ERROR`, `PROOF_TRANSFORMATION_ERROR`, `INVALID_DOMAIN_ERROR`, `INVALID_CHALLENGE_ERROR`, and `INVALID_VERIFICATION_METHOD` for the controller-authorization failure above) rather than throwing on invalid proofs; exceptions are reserved for malformed inputs and misconfiguration.

**FR-4 — Cryptosuite registry.** An open registry keyed by cryptosuite name. Each suite supplies its transformation, hashing, and proof-serialization implementations to the pipeline. Registering a 1.1-track revision or a deferred suite (`ecdsa-sd-2023`) must require no pipeline changes. Built-in registrations per package: `Core` registers the JCS suites; `Rdfc` registers the RDFC suites and `bbs-2023`. *Port reference:* zcap-dotnet's `ICryptoSuiteProvider`/`CryptoSuiteProvider` and `IDocumentCanonicalizer`/`DocumentCanonicalizerProvider` abstractions.
**FR-4 — Cryptosuite registry.** An open registry keyed by cryptosuite name. Each suite supplies its transformation, hashing, and proof-serialization implementations to the pipeline. Registering a 1.1-track revision or a deferred suite (`ecdsa-sd-2023`) must require no pipeline changes. Built-in registrations per package: `Core` registers the JCS suites; `Rdfc` registers the RDFC suites and `bbs-2023`. *Port reference:* zcap-dotnet's `ICryptoSuiteProvider`/`CryptoSuiteProvider` and `IDocumentCanonicalizer`/`DocumentCanonicalizerProvider` abstractions. **Legacy/type-named suites (verification).** The "no pipeline changes" contract also covers *verifying* pre–Data-Integrity Linked-Data-Signature proofs that name their algorithm by `type` (`Ed25519Signature2020`, `EcdsaSecp256r1Signature2019`, …) and carry **no** `cryptosuite` member. A suite declares the proof `type`(s) it verifies via `ICryptosuite.SupportedProofTypes` (a default interface member returning the single `DataIntegrityProof` type, so the 2022/2019 suites are unchanged and still dispatched by `cryptosuite` name). The registry maintains a secondary `type → suite` index for the *non-default* types only, and the verify pipeline dispatches by `cryptosuite` name first, falling back to proof `type` for legacy proofs — `cryptosuite`-named suites always win, the default type is never type-indexed, and each suite still re-validates `type`/`cryptosuite`/key/encoding/signature itself. The library still **creates** only conformant 2022/2019 proofs; legacy support is verification-only, and shipping concrete legacy suites is out of scope (a consumer or a future `DataProofsDotnet.Legacy` package supplies them).

**FR-5 — JCS suites.** Conformant `eddsa-jcs-2022` and `ecdsa-jcs-2019` (P-256 and P-384): proof-configuration canonicalization with JCS via NetCid, document canonicalization with JCS, `hashData = hash(canonicalProofConfig) ‖ hash(canonicalDocument)` with the hash function the suite mandates per curve, signing via NetCrypto, `proofValue` as base58-btc multibase. Verified against the W3C test vectors and interop-suite fixtures. **Porting caveat:** zcap-dotnet's JCS canonicalizer transfers, but its JCS *signing payload* uses the 2020-era embedded-proof convention (document signed with proof nested in it); `eddsa-jcs-2022`/`ecdsa-jcs-2019` require the separate-proof-config canonicalization + hash-concat that zcap currently applies only on its RDFC path — extend that mechanic to JCS here (§1.4).

Expand Down
75 changes: 74 additions & 1 deletion src/DataProofsDotnet.Core/DataIntegrity/CryptosuiteRegistry.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,32 @@ namespace DataProofsDotnet.DataIntegrity;
/// Thread-safe; registration replaces any existing suite of the same name (last
/// registration wins).
/// </summary>
/// <remarks>
/// A secondary index maps a proof <c>type</c> to the suite that declares it via
/// <see cref="ICryptosuite.SupportedProofTypes"/>, so the verify pipeline can dispatch
/// legacy Linked-Data-Signature proofs (which name their algorithm by <c>type</c> and carry
/// no <c>cryptosuite</c>) through <see cref="GetByProofType"/>. The default Data Integrity
/// type (<see cref="DataIntegrityProof.DataIntegrityProofType"/>) is deliberately excluded
/// from this index: the shipped 2022/2019 suites all share that single type and are always
/// disambiguated by <c>cryptosuite</c> name, never by type.
/// <para>
/// If two suites registered under <em>different</em> names both declare the same non-default
/// proof <c>type</c>, the type index is last-registration-wins (mirroring the name index).
/// This is benign: both suites claim the type and each fully re-validates the proof in its
/// own <see cref="ICryptosuite.VerifyProof"/>, so a "wrong" selection fails closed rather than
/// accepting. Shipping suites that overlap on a legacy type is therefore discouraged.
/// </para>
/// </remarks>
public sealed class CryptosuiteRegistry
{
private readonly ConcurrentDictionary<string, ICryptosuite> _suites = new(StringComparer.Ordinal);

/// <summary>Secondary index: non-default proof <c>type</c> → suite (legacy suites only).</summary>
private readonly ConcurrentDictionary<string, ICryptosuite> _suitesByProofType = new(StringComparer.Ordinal);

/// <summary>Serializes registration so the two indexes update atomically; reads stay lock-free.</summary>
private readonly object _registrationLock = new();

/// <summary>Creates an empty registry.</summary>
public CryptosuiteRegistry()
{
Expand All @@ -33,13 +55,64 @@ public void Register(ICryptosuite suite)
{
ArgumentNullException.ThrowIfNull(suite);
ArgumentException.ThrowIfNullOrEmpty(suite.Name);
_suites[suite.Name] = suite;

var newTypes = NonDefaultProofTypes(suite).ToHashSet(StringComparer.Ordinal);

lock (_registrationLock)
{
_suites.TryGetValue(suite.Name, out var previous);

// Publish the new suite's type entries BEFORE pruning the old ones, so a type
// claimed by both the replaced and the replacing suite is repointed atomically —
// a concurrent GetByProofType never observes it transiently absent.
foreach (var type in newTypes)
{
_suitesByProofType[type] = suite;
}

_suites[suite.Name] = suite;

// Prune the previous suite's type entries that the new suite no longer claims.
// Value-matched so a different-named suite that owns the same type is left intact
// (last-registration-wins; see the class remarks), and re-registering a suite that
// still claims the type stays idempotent.
if (previous is not null)
{
foreach (var type in NonDefaultProofTypes(previous))
{
if (!newTypes.Contains(type))
{
((ICollection<KeyValuePair<string, ICryptosuite>>)_suitesByProofType)
.Remove(new KeyValuePair<string, ICryptosuite>(type, previous));
}
}
}
}
}

/// <summary>Returns the suite registered under <paramref name="name"/>, or <c>null</c>.</summary>
public ICryptosuite? GetByName(string? name)
=> string.IsNullOrEmpty(name) ? null : _suites.GetValueOrDefault(name);

/// <summary>
/// Returns the suite that declares <paramref name="type"/> in its
/// <see cref="ICryptosuite.SupportedProofTypes"/>, or <c>null</c>. Resolves only
/// legacy/type-named suites: the default Data Integrity type
/// (<see cref="DataIntegrityProof.DataIntegrityProofType"/>), <c>null</c>, and empty
/// always return <c>null</c>, because those proofs are dispatched by <c>cryptosuite</c>
/// name (<see cref="GetByName"/>), never by type. When two differently named suites
/// declare the same type, the most recently registered one is returned (see the class
/// remarks); the verify pipeline re-confirms the returned suite claims the type.
/// </summary>
public ICryptosuite? GetByProofType(string? type)
=> string.IsNullOrEmpty(type) ? null : _suitesByProofType.GetValueOrDefault(type);

/// <summary>The names of all registered suites.</summary>
public IReadOnlyCollection<string> RegisteredNames => _suites.Keys.ToArray();

/// <summary>The suite's declared proof types excluding the default Data Integrity type.</summary>
private static IEnumerable<string> NonDefaultProofTypes(ICryptosuite suite)
=> (suite.SupportedProofTypes ?? []).Where(
type => !string.IsNullOrEmpty(type)
&& !string.Equals(type, DataIntegrityProof.DataIntegrityProofType, StringComparison.Ordinal));
}
Original file line number Diff line number Diff line change
Expand Up @@ -381,8 +381,14 @@ private async Task<ProofVerificationResult> VerifySingleProofAsync(
}
}

var suite = _registry.GetByName(proof.Cryptosuite);
if (suite is null || !string.Equals(proof.Type, DataIntegrityProof.DataIntegrityProofType, StringComparison.Ordinal))
// Suite dispatch (FR-4): the on-wire cryptosuite name always wins (the 2022/2019
// generation), falling back to the proof type for legacy Linked-Data-Signature
// proofs that name their algorithm by type and carry no cryptosuite. The suite must
// claim the type; it then re-validates type/cryptosuite/key/encoding/signature
// itself, so this gate only confirms the dispatch target is willing to handle it.
var suite = _registry.GetByName(proof.Cryptosuite)
?? _registry.GetByProofType(proof.Type);
if (suite is null || !(suite.SupportedProofTypes ?? []).Contains(proof.Type))
{
return ProofVerificationResult.Failure(
ProofProblemCodes.ProofVerificationError,
Expand Down
18 changes: 18 additions & 0 deletions src/DataProofsDotnet.Core/DataIntegrity/ICryptosuite.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,24 @@ public interface ICryptosuite
/// <summary>The cryptosuite identifier this suite registers under (e.g. <c>eddsa-jcs-2022</c>).</summary>
string Name { get; }

/// <summary>
/// The proof <c>type</c> values this suite verifies. Defaults to the Data Integrity
/// generation's single type (<see cref="DataIntegrityProof.DataIntegrityProofType"/>),
/// so existing suites continue to be dispatched by <c>cryptosuite</c> name exactly as
/// today. A legacy Linked-Data-Signature suite (e.g. <c>Ed25519Signature2020</c>,
/// <c>EcdsaSecp256r1Signature2019</c>) overrides this to its <c>type</c> string and
/// leaves its proofs' <c>cryptosuite</c> null — the verify pipeline then dispatches such
/// proofs to it by <c>type</c>. The suite itself remains responsible for fully validating
/// the proof's <c>type</c>/<c>cryptosuite</c>/key/encoding inside
/// <see cref="VerifyProof"/>; this collection only declares which types it claims.
/// </summary>
IReadOnlyCollection<string> SupportedProofTypes => DefaultSupportedProofTypes;

/// <summary>The default <see cref="SupportedProofTypes"/> value — the single
/// Data Integrity proof type — shared so the common case allocates nothing per call.</summary>
private static readonly IReadOnlyCollection<string> DefaultSupportedProofTypes =
[DataIntegrityProof.DataIntegrityProofType];

/// <summary>
/// Creates a proof over <paramref name="unsecuredDocument"/> per this suite's
/// transform → hash → serialize pipeline. For proof chains the pipeline passes a
Expand Down
2 changes: 2 additions & 0 deletions src/DataProofsDotnet.Core/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const DataProofsDotnet.DataIntegrity.ProofPurposes.KeyAgreement = "keyAgreement"
DataProofsDotnet.DataIntegrity.CryptosuiteRegistry
DataProofsDotnet.DataIntegrity.CryptosuiteRegistry.CryptosuiteRegistry() -> void
DataProofsDotnet.DataIntegrity.CryptosuiteRegistry.GetByName(string? name) -> DataProofsDotnet.DataIntegrity.ICryptosuite?
DataProofsDotnet.DataIntegrity.CryptosuiteRegistry.GetByProofType(string? type) -> DataProofsDotnet.DataIntegrity.ICryptosuite?
DataProofsDotnet.DataIntegrity.CryptosuiteRegistry.Register(DataProofsDotnet.DataIntegrity.ICryptosuite! suite) -> void
DataProofsDotnet.DataIntegrity.CryptosuiteRegistry.RegisteredNames.get -> System.Collections.Generic.IReadOnlyCollection<string!>!
DataProofsDotnet.DataIntegrity.DataIntegrityProof
Expand Down Expand Up @@ -74,6 +75,7 @@ DataProofsDotnet.DataIntegrity.EddsaJcs2022Cryptosuite.VerifyProof(System.Text.J
DataProofsDotnet.DataIntegrity.ICryptosuite
DataProofsDotnet.DataIntegrity.ICryptosuite.CreateProofAsync(System.Text.Json.JsonElement unsecuredDocument, DataProofsDotnet.DataIntegrity.DataIntegrityProof! proofOptions, NetCrypto.ISigner! signer, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<DataProofsDotnet.DataIntegrity.DataIntegrityProof!>!
DataProofsDotnet.DataIntegrity.ICryptosuite.Name.get -> string!
DataProofsDotnet.DataIntegrity.ICryptosuite.SupportedProofTypes.get -> System.Collections.Generic.IReadOnlyCollection<string!>!
DataProofsDotnet.DataIntegrity.ICryptosuite.VerifyProof(System.Text.Json.JsonElement unsecuredDocument, DataProofsDotnet.DataIntegrity.DataIntegrityProof! proof, DataProofsDotnet.PublicKeyMaterial! publicKey) -> DataProofsDotnet.DataIntegrity.ProofVerificationResult!
DataProofsDotnet.DataIntegrity.IVerificationMethodResolver
DataProofsDotnet.DataIntegrity.IVerificationMethodResolver.ResolveAsync(string! verificationMethodUrl, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<DataProofsDotnet.DataIntegrity.ResolvedVerificationMethod?>!
Expand Down
Loading
Loading