name: verifiable-credentials description: | Implements W3C Verifiable Credentials 2.0 (SD-JWT VC profile), DID documents, and selective disclosure across Sorcha services and UI. Use when: issuing or verifying VCs, building DID documents, resolving did:sorcha, wiring credential wallet UI, implementing SD-JWT presentations, or working on feature 093 VC security fixes. allowed-tools: Read, Edit, Write, Glob, Grep, Bash, mcp__context7__resolve-library-id, mcp__context7__query-docs
Verifiable Credentials Skill
Sorcha implements W3C Verifiable Credentials 2.0 using the SD-JWT VC profile (RFC 9901 + SD-JWT VC). The ecosystem choice deliberately avoids JSON-LD canonicalisation and Data Integrity Proofs — instead, SD-JWT provides compact serialisation, built-in selective disclosure, and plays well with existing JWT infrastructure. All SD-JWT primitives live in Sorcha.Cryptography.SdJwt; credential issuance, verification, and selective disclosure sit in Sorcha.Blueprint.Engine/Credentials/.
In-flight context (feature 093). There is an active spec at specs/093-vc-security-fixes/ that is hardening VC security, including the publicKeyMultibase encoding bug that prompted the Multicodec utility. Read specs/093-vc-security-fixes/spec.md before touching the DID resolver or multibase encoding paths.
Unified trust + mdoc (feature 135, shipped). Verification trust is now decided by ONE ITrustEvaluator (Sorcha.Blueprint.Engine.Credentials) consulted by BOTH the engine CredentialVerifier and HAIP's verifier — CredentialRequirement.AcceptedIssuers is gone, replaced by a TrustPolicy of pluggable trust sources; the old CredentialVerifier SignatureValid=false shortcut is removed (signatures verify for real, fail-closed). A second credential format — ISO mso_mdoc (CBOR/COSE, Sorcha.Cryptography/Mdoc) — sits beside SD-JWT VC behind an ICredentialFormatHandler seam (online/OpenID4VP only). Read the "EUDI credential format & unified trust (Feature 135)" section of the sorcha-architecture skill before touching credential verification, issuance, trust policy, or mdoc.
The .NET VC ecosystem is sparse. Do not look for a turnkey NuGet package. Sorcha implements SD-JWT VC directly against the spec using System.Text.Json and Sorcha.Cryptography. The existing CredentialIssuer, CredentialVerifier, BitstringStatusListChecker, and SdJwtService types are the canonical implementations — extend them rather than starting over.
Quick Start
Existing Building Blocks (real file paths)
| Component | Location | Purpose |
|---|---|---|
ICredentialIssuer / CredentialIssuer |
src/Core/Sorcha.Blueprint.Engine/Credentials/ |
Builds + signs SD-JWT VCs from action execution data |
ICredentialVerifier / CredentialVerifier |
src/Core/Sorcha.Blueprint.Engine/Credentials/ |
Verifies presentations against action credential requirements |
BitstringStatusListChecker |
src/Core/Sorcha.Blueprint.Engine/Credentials/ |
W3C Bitstring Status List revocation checks |
ISdJwtService / SdJwtService |
src/Common/Sorcha.Cryptography/SdJwt/ |
RFC 9901 SD-JWT primitives — create, verify, present |
SdJwtToken / SdJwtPresentation |
src/Common/Sorcha.Cryptography/SdJwt/ |
Token + presentation types |
CredentialIssuanceConfig |
src/Common/Sorcha.Blueprint.Models/Credentials/ |
Blueprint action credential minting config |
CredentialRequirement |
src/Common/Sorcha.Blueprint.Models/Credentials/ |
Action precondition: "present this type of VC" |
CredentialPresentation |
src/Common/Sorcha.Blueprint.Models/Credentials/ |
Holder-submitted SD-JWT presentation |
IDidResolverRegistry + IDidResolver |
src/Common/Sorcha.ServiceClients.Http/Did/ |
DID resolution — SorchaDidResolver, WebDidResolver, KeyDidResolver |
DidDocument |
src/Common/Sorcha.ServiceClients.Http/Did/ |
W3C DID Core document |
Multicodec |
src/Common/Sorcha.ServiceClients.Http/Utilities/ |
publicKeyMultibase varint + base58btc encoding |
ICredentialStore (server) |
src/Services/Sorcha.Wallet.Service/Credentials/ |
Wallet-side credential persistence |
CredentialMatcher |
src/Services/Sorcha.Wallet.Service/Credentials/ |
Matches stored credentials against requirements |
PresentationRequestService |
src/Services/Sorcha.Wallet.Service/Services/ |
Builds SD-JWT presentations with selective disclosure |
Issuing a VC — the actual API shape
// Blueprint action execution reaches the point where a credential should be minted.
// Signature matches ICredentialIssuer.IssueAsync — no VerifiableCredential record,
// the issuer builds the SD-JWT directly from the config + processed data.
public async Task<IssuedCredentialInfo> MintAsync(
CredentialIssuanceConfig config,
Dictionary<string, object> processedData,
string issuerDid, // e.g. "did:sorcha:org:ws1q..."
string recipientDid, // e.g. "did:sorcha:w:ws1q..."
byte[] issuerSigningKey,
string algorithm, // "EdDSA" | "ES256"
CancellationToken ct)
{
return await _credentialIssuer.IssueAsync(
config, processedData, issuerDid, recipientDid, issuerSigningKey, algorithm, ct);
}
The signing key is the org's VC-issuance key, derived under KeyUsage.VCIssuance (Feature 083 slot 1) via IOrgKeyDerivationService and surfaced by IIssuanceKeyService.GetActiveSigningMaterialAsync (Feature 120) as IssuanceSigningMaterial (issuer DID + a #vc-issuance-{rotationIndex} kid + the private key bytes) — never the root wallet key. There is no "sorcha:vc-issuance" derivation-purpose string; the key is selected by KeyUsage, not a purpose label.
Verifying a presentation — the actual API shape
// Verifier takes action requirements + submitted presentations.
// Returns a CredentialValidationResult with per-requirement success/failure.
var result = await _credentialVerifier.VerifyAsync(
action.CredentialRequirements,
submittedPresentations,
ct);
if (!result.IsValid)
_logger.LogWarning("Credential verification failed: {Errors}",
string.Join(", ", result.Errors.Select(e => e.Message)));
This runs in both the Blueprint Service (server-side action execution) and the holder's UI (pre-flight check before submitting). The verifier is portable — no HttpClient, no platform APIs — so it works in Blazor WASM too.
Key Concepts
| Concept | Usage | Example |
|---|---|---|
did:sorcha:org:{walletAddress} |
Organisation issuer DID | did:sorcha:org:ws1q8tuvvd... |
did:sorcha:w:{walletAddress} |
Wallet / holder DID | did:sorcha:w:ws1q8tuvvd... |
did:sorcha:r:{registerId}:t:{txId} |
Register transaction reference | did:sorcha:r:abc:t:xyz |
| SD-JWT compact form | Wire format | <jwt>~<disclosure1>~<disclosure2>~<kb-jwt> |
ClaimMapping |
Blueprint field → VC claim | /applicant/name → credentialSubject.name |
ClaimConstraint |
Verifier requirement | "must equal X" / "must exist" |
BitstringStatusList |
Revocation mechanism | 131K entries per status credential |
RevocationCheckPolicy |
FailClosed / FailOpen | Default is FailClosed |
Core Models — actual locations
All credential domain models live in Sorcha.Blueprint.Models.Credentials (note: Blueprint.Models, not Blueprint.Engine.Credentials.Models).
// Sorcha.Blueprint.Models/Credentials/CredentialIssuanceConfig.cs
public class CredentialIssuanceConfig
{
public string CredentialType { get; set; } = string.Empty;
public IEnumerable<ClaimMapping> ClaimMappings { get; set; } = [];
public string RecipientParticipantId { get; set; } = string.Empty;
// ... plus display config, status list binding, validity window
}
// Sorcha.Blueprint.Models/Credentials/CredentialRequirement.cs
public class CredentialRequirement
{
public string Type { get; set; } = string.Empty;
public CredentialFormat Format { get; set; } = CredentialFormat.SdJwtVc; // feature 135 (SdJwtVc | MsoMdoc)
public TrustPolicy? TrustPolicy { get; set; } // feature 135 — replaced AcceptedIssuers
public IEnumerable<ClaimConstraint>? RequiredClaims { get; set; }
public RevocationCheckPolicy RevocationCheckPolicy { get; set; }
}
// NOTE (feature 135): the flat AcceptedIssuers list was REMOVED from CredentialRequirement.
// Issuer trust is now a TrustPolicy decided by the unified ITrustEvaluator. See the
// "EUDI credential format & unified trust (Feature 135)" section of the sorcha-architecture skill.
// Sorcha.Blueprint.Models/Credentials/CredentialPresentation.cs
public class CredentialPresentation
{
public string CredentialId { get; set; } = string.Empty; // DID URI of the VC
public Dictionary<string, object> DisclosedClaims { get; set; } = new();
public string RawPresentation { get; set; } = string.Empty; // SD-JWT compact form
// ... plus key-binding JWT
}
These are mutable class types with set; properties — Sorcha chose this over records for JSON round-trip compatibility with DataAnnotations validation. Do not redesign them as records.
DID Documents
DidDocument is a mutable class in Sorcha.ServiceClients.Did, serialised with System.Text.Json. It is not a record, does not include @context, and carries both publicKeyJwk and publicKeyMultibase on each verification method.
public class DidDocument
{
public required string Id { get; set; }
public IReadOnlyList<VerificationMethod> VerificationMethod { get; set; } = [];
public IReadOnlyList<string>? Authentication { get; set; }
public IReadOnlyList<string>? AssertionMethod { get; set; }
public IReadOnlyList<ServiceEndpoint>? Service { get; set; }
}
public class VerificationMethod
{
public required string Id { get; set; }
public required string Type { get; set; } // "Ed25519VerificationKey2020", "JsonWebKey2020"
public required string Controller { get; set; }
public JsonElement? PublicKeyJwk { get; set; }
public string? PublicKeyMultibase { get; set; } // z-prefixed, encoded via Multicodec
}
Resolution goes through IDidResolverRegistry — it dispatches to the method-specific resolver (SorchaDidResolver, WebDidResolver, KeyDidResolver). Call it directly, do not reinvent:
public class MyVerifier(IDidResolverRegistry dids)
{
public async Task<DidDocument?> ResolveIssuerAsync(string did, CancellationToken ct)
=> await dids.ResolveAsync(did, ct);
}
Multicodec is the canonical publicKeyMultibase encoder. Feature 093 introduced it after the original DID resolver was emitting literal "z" + hex/base64 — which is malformed. Always use:
// Algorithm names match Sorcha.Cryptography's wallet algorithm strings — NOT JOSE "alg" values.
// Accepted: "ED25519", "NIST-P256" / "P-256" / "P256" / "ECDSA-P256", "RSA" / "RSA-4096".
// Passing "EdDSA" or "ES256" silently returns null — that is the feature 093 bug class.
string? multibase = Multicodec.ToMultibasePublicKey("ED25519", rawPublicKeyBytes);
// Returns null for PQC algorithms (ML-DSA, SLH-DSA, ML-KEM) that have no assigned codec —
// callers must fall back to publicKeyJwk or fail closed per FR-014 in spec 093.
Never hand-roll "z" + Base58.Encode(publicKey) — that was the original bug.
Org VC-Issuer Signing & DID Anchoring (the three-address model)
Hard-won during the CyberEssentialsUac live-test blocker (2026-06). Read this before touching org credential issuance,
did:sorcha:org:, trust allowlists, or the issuer-key resolver — the model is split-brained and the seams are non-obvious.
A single credential-issuing org has three distinct identities, and did:sorcha:org:{addr} does not mean the same addr everywhere:
| Identity | How created | did:sorcha:org:{…} used for |
|
|---|---|---|---|
| A | Operational / participant wallet — Organization.WalletAddress |
plain New-SorchaWallet, linked as a participant |
register ownership, governance roster, register invitations (RegisterInvitationService), X.509 org-cert SAN (InternalCaTrustProvider.IssueOrgCertAsync), trust did-allowlist pins, participant publishing |
| B | Feature 083 org master seed | Set-SorchaOrgMasterKey → OrgKeyDerivationService.ProvisionMasterKeyAsync |
none — it has no address; it is an encrypted BIP39 seed + master public key, a derivation root only |
| C | Derived VC-issuance child wallet (KeyUsage.VCIssuance, F083 slot 1, F120) |
lazily derived by IssuanceKeyService.GetOrDeriveAsync under B |
the credential's iss, the published did.json id, kid = …#vc-issuance-{n}, and what IOrgDidDocumentClient.ResolveCanonicalDidAsync returns (the F127 verifier client_id) |
The split-brain: did:sorcha:org:{A} is canonical for register/trust/invitation/X.509, but issuance + did.json + the F127 verifier identity all use did:sorcha:org:{C}. A and C never match (C is a BIP32 child of B with its own address). This is the root cause behind a cluster of latent bugs:
- An org with NO master key signs with its root wallet key and emits
iss= a bare wallet address (not adid:), with nokidand nojwkin the JWS header → the issuer key is unresolvable →TrustEvaluator: issuer signature not verified. (CredentialEndpoints.csfallback whenIssuanceKeyService.GetActiveSigningMaterialAsyncreturns null.) - The dev "embedded JWS
jwk" resolver path can never fire —SdJwtServiceissuance writes onlyalg/typ/optionalkid/optionalx5c; it has no code path that embedsjwk. Don't propose "embed the issuer JWK for dev verification" without first adding the emit side. - A
did-allowlistpinningdid:sorcha:org:{A}won't match a credential whoseissisdid:sorcha:org:{C}(noalsoKnownAsbridges them). Adding a master key to an org that pins its operational DID silently breaks its own trust check. - Blueprint Service's verifier doesn't read the published did.json.
SorchaDidResolveris wired there with the 2-arg ctor (noHttpClient), so it skips the Tenant/orgs/{id}/did.jsonfetch and rebuilds the doc locally from the wallet row, synthesising a hardcoded#vc-issuance-1VM. Works only for rotation index 1 —#vc-issuance-2won't match → latent rotation bug.
The correct fix (identified, not yet implemented): re-anchor the issuer iss/kid/did.json id from the derived child C to the operational wallet A (publish C's public key as a VM under did:sorcha:org:{A}, e.g. did:sorcha:org:{A}#vc-issuance-{n}). Edit points: IssuanceKeyService.cs:128,233-234 + OrgDidDocumentService.cs:52 (change together + regenerate cached docs, or the verifier fails closed). Blast radius is strictly positive — fixes F127 client_id, VC-iss↔invitation↔X.509-SAN consistency, and the allowlist footgun; tests assert the #vc-issuance-{n} suffix, not the address. Add the old C-DID to alsoKnownAs for backward-compat with already-issued creds. (Anchoring to the stable orgId GUID would be even more rotation-proof but touches all the A-consumers — a bigger, separate decision.)
Walkthrough rule: any org that issues native SorchaLocalWallet VCs MUST call Set-SorchaOrgMasterKey for that org, or it falls to the bare-wallet-iss path above. ForestryCertification/TradeFinance/SelfBuildHouse do; CyberEssentialsUac/AssuredIdentity historically did not (they only provisioned HAIP enrolment).
Standards conformance of the issuer-signing model
| Item | Verdict |
|---|---|
F120 DID path (iss=did, kid-matched assertionMethod VM, cnf+KB-JWT) |
conformant profile choice — DID is a sanctioned iss form; assertionMethod is the right relationship for issuer keys |
iss = bare wallet address (no-master-key fallback) |
divergence — not a URI/DID, no kid/jwk; unresolvable by any conformant verifier. Should fail closed at issuance rather than mint an unverifiable credential |
typ = "vc+sd-jwt" |
drift — the current SD-JWT VC draft renamed the media subtype to dc+sd-jwt. Plan a typ migration (accept both on verify during transition) |
embedded-jwk dev resolver path |
off-spec (self-certifying issuer); acceptable only if strictly non-prod |
.well-known/jwt-vc-issuer metadata path |
not implemented — legitimately substituted by DID resolution |
Two trust rails — pick by who the verifier is
Credential trust runs on two distinct rails; reaching for the wrong one ("use the X.509 CA so my credential is trusted") is a common mistake.
- Rail 1 — register/DID-native (intra-ecosystem). Verifiers inside Sorcha (engine
CredentialVerifier, HAIP verifier, another Sorcha node) anchor on the register (wallet signatures + validator roster) +did:sorcha:org:resolution. No X.509, no external CA. The register is the trust root (DAD model). This is the correct rail whenever the verifier is itself a Sorcha participant (e.g. an insurer org consuming an assessor's credential). - Rail 2 — X.509/x5c (EUDI/external bridge). Verifiers outside Sorcha that only speak PKI (EUDI wallets, third parties) need a cert chain to a root they already trust. F135's
CredentialIssuanceConfig.TrustAnchor=x509-tenant(per-tenant self-signed root,InternalCaTrustProvider) vsx509-lotl(external trusted-list anchor).
Current-state gap: the X.509 rail is intra-ecosystem only today. The internal CA is self-signed, so no external party trusts it without planting Sorcha's root. Genuine external interop needs x509-lotl (chain to a CA on a recognised List of Trusted Lists) — and LOTL is deferred. Plus two stacked blockers: X509CertificateBuilder is P-256-only (Ed25519 org keys → ASN1 corrupted data enrol 500), and org-cert enrol (POST /api/v1/trust/tenants/{id}/orgs/{wallet}/enrol) is an explicit admin API invoked only by HAIP walkthrough setup — no auto-enrol on org creation, no admin UI. So "the org has an externally-usable X.509 identity" is not a state any normal org reaches. Full F135 detail: sorcha-architecture skill → "Two trust rails: register/DID-native vs X.509/EUDI-external".
Selective Disclosure (SD-JWT)
SD-JWT compact form: <issuer JWT>~<disclosure1>~<disclosure2>~...~<key binding JWT>.
- Issuer (
SdJwtService.CreateAsync): picks which claims are selectively disclosable, hashes each disclosure, embeds the hashes in the JWT payload under_sd. - Holder (
SdJwtService.PresentAsync): strips disclosures not requested by the verifier, signs a key-binding JWT with nonce + audience. - Verifier (
SdJwtService.VerifyAsync): verifies issuer signature, recomputes disclosure hashes, verifies key-binding JWT, checks status list.
PresentationRequestService in the Wallet Service is the holder-side orchestrator. Feature 093 added stricter verification (nonce binding, audience check, revocation fail-closed) in PresentationRequestVerificationTests.cs — read those tests to understand the contract.
Blueprint Integration
Actions carry credential configs as first-class fields (not an x-* schema extension). The real JSON shape uses claimName + sourceField on each mapping, and the set of selectively-disclosable claims is declared once on the config via the disclosable array.
Feature 103: nested source paths. sourceField is a JSON Pointer, so it can resolve nested values from Sorcha core primitive references.
- A blueprint that references
PersonName/v1via$refcan map/name/givenName,/name/familyName,/name/fullNameetc. directly. - The
ActionExecutionService.BuildClaimsFromMappingswalker is used by both the internal issuance path and the HAIP external-wallet path. It descends nestedDictionary<string, object?>andJsonElementstructures and applies RFC 6901~1/~0escape decoding. - Missing source values are logged at
LogWarningand the claim is dropped from the credential — silently issuing a credential with fewer attributes than the action promised is a correctness defect worth surfacing. - See
ActionExecutionService.BuildClaimsFromMappingsandTryResolveJsonPointerfor the walker, and theHaipVerifiedCitizenwalkthrough for a worked example.
{
"actionId": "issue-graduation",
"credentialIssuance": {
"credentialType": "GraduationCredential",
"recipientParticipantId": "student",
"claimMappings": [
{ "claimName": "name", "sourceField": "/student/name" },
{ "claimName": "graduationDate", "sourceField": "/student/graduationDate" }
],
"disclosable": ["graduationDate"],
"expiryDuration": "P10Y",
"usagePolicy": "Reusable"
},
"credentialRequirements": [
{
"type": "IdentityAttestation",
"trustPolicy": {
"sources": [
{ "kind": "did-allowlist", "allowedIssuers": ["did:sorcha:org:ws1q..."] }
]
},
"revocationCheckPolicy": "FailClosed"
}
]
}
Feature 135:
CredentialRequirement.acceptedIssuerswas removed and replaced bytrustPolicy(asources[]+combinator+minAssuranceLevelshape decided by the unifiedITrustEvaluator). Thedid-allowlistsource above is the direct equivalent of the old flat issuer list; omittrustPolicy(or use a{ "kind": "register" }source) to trust register-resolved issuers. Full shape: the "EUDI credential format & unified trust (Feature 135)" section of thesorcha-architectureskill. Do not writeacceptedIssuers— it is silently ignored.
expiryDuration is an ISO 8601 duration string (P10Y, P90D), not a TimeSpan.
ActionExecutionService reads CredentialRequirements before the action runs and CredentialIssuance after. No custom per-blueprint credential code.
Citizen-PWA delivery (Feature 114 US4)
When credentialIssuanceConfig.targetAudience: "SorchaLocalWallet" and the resolved recipient wallet is a citizen's holder wallet (slot 108), the credential is delivered to the citizen-PWA inbox with optional SignalR push. The flow lives entirely in Wallet Service — Blueprint Service is unchanged from the org-credential path.
ActionExecutionService (Blueprint Service)
↓ AEAD-encrypts SD-JWT VC to recipient wallet's X25519 key
↓ submits credential-issuance transaction
Validator seals docket
↓
InboundCredentialDetector.TryExtractAsync (Wallet Service)
↓ decrypts envelope with recipient wallet's X25519 private key
↓ persists CredentialEntity
→ CredentialStore.AddAsync(credential)
→ ICitizenInboxProjector.OnCredentialAddedAsync(credential)
↓ IHolderAddressLookup.ResolvePlatformUserIdAsync(recipientAddress)
↓ null → org credential, no-op (existing org-credential path takes over)
↓ guid → citizen credential
↓ insert CitizenCredentialEventLog row, Seq = MAX(Seq)+1 per PlatformUserId
↓ try { hub.Clients.Group(WalletHub.GroupNameFor(pid)).CredentialAvailable(id) }
catch { log; swallow } // pull-on-open /sync stays authoritative
Status mutations follow the same projector seam: CredentialStore.PatchStatusAsync and UpdateStatusAsync invoke ICitizenInboxProjector.OnCredentialStatusChangedAsync after a successful mutation. Active→Revoked/Declined writes a Revoked event-log entry; replacement transitions write a Replaced entry.
Authority model. The hub emit is an optimisation; the /sync endpoint reading CitizenCredentialEventLog via EfCoreCitizenCredentialEventStream is authoritative. Closing the PWA before issuance and reopening after still surfaces the credential because the projector wrote the log row regardless of hub-emit success.
Key index population. CitizenHolderIndex ((WalletAddress → PlatformUserId)) is written from CitizenWalletEndpoints.EnrolDevice at the one moment the citizen JWT carries both the wallet address and the platform user id. Without that row, IHolderAddressLookup returns null and the credential falls back to the org path — meaning citizen-credential push only works for citizens who have completed at least one device enrolment.
Worked-example blueprint (council issuing Assured Identity to a late-bound citizen applicant) is in .claude/skills/blueprint-builder/SKILL.md and .claude/skills/sorcha-architecture/SKILL.md § "Citizen Wallet PWA (Feature 114)".
Holder→device delegation: algorithm support & the /verify diagnostic panel
The citizen presentation chain is curve-mixed by construction, so any single-algorithm assumption is a bug:
| Key | Curve | Why |
|---|---|---|
| Device / KB-JWT | always EC P-256 / ES256 | WebCrypto non-extractable key in the browser |
Holder (signs the device delegation; the credential's cnf.jwk) |
derives from the wallet algorithm — Ed25519 (OKP/EdDSA) for the default Sorcha wallet, P-256 for a P-256 wallet (HolderKeyService, slot 108) |
the holder is the citizen's wallet |
| Issuer (credential JWS) | Ed25519 or P-256 (org VC-issuance key) | org wallets are frequently Ed25519 |
VerifiablePresentationValidator.VerifyJwsSignature dispatches on the JWS header alg and verifies both ES256 (EC, via ECDsa) and EdDSA (Ed25519/OKP, via BouncyCastle — pure-managed so it works in a Blazor WASM host where libsodium P/Invoke does not). DeviceDelegationIssuer emits the honest header alg from the holder key type (EdDSA for Ed25519) — a hardcoded ES256 over an Ed25519 signature is unverifiable and was the cause of "Delegation credential signature verification failed against holder key." on default (Ed25519) wallets.
The decoded key facts ride the Feature 155 verdict trail (VerificationOutcome.Layers), not a separate structure: the LivePresentation layer's Detail carries holder-key ("OKP / Ed25519" or "EC / P-256") and delegation ("{alg} · device key {kty/crv}"), alongside the existing IssuerSignature layer's alg. The Open Verifier PWA (Sorcha.Verifier) renders every layer's Detail dictionary generically (Outcome.razor), so an operator reads holder-key OKP / Ed25519 in the "Live presentation" panel with no browser dev tools — no bespoke diagnostic surface required.
⚠ Latent (deferred):
DeviceEnrolmentResponse.HolderPublicJwkis still typedEcP256PublicJwkand coerces an Ed25519 holder JWK to aY=""P-256 shape (CitizenWalletEndpoints.ParseHolderJwk). It is a verifier-convenience copy that no consumer reads — the verifier takes the holder key from the credential'scnf.jwk, not this field — so it is not on the failure path. Widen it to a faithful JWK when a consumer actually needs it.
MAUI Blazor UI
The server already has Sorcha.Wallet.Service.Credentials.ICredentialStore. The UI needs a separate render-mode-agnostic abstraction — use ICredentialUiStore under Sorcha.UI.Core/Services/Credentials/ to avoid naming collision. Platform services (SecureStorage, biometrics) hide behind IBiometricGate and IQrScanner. Razor components never touch MAUI APIs directly.
// Razor shell (render-mode agnostic — works in InteractiveServer and InteractiveWebAssembly)
@inject ICredentialUiStore Store
@inject IBiometricGate Biometric
@inject ISdJwtService SdJwt
<CredentialList Credentials="_credentials" OnPresent="HandlePresentAsync" />
@code {
private IReadOnlyList<StoredCredentialSummary> _credentials = [];
protected override async Task OnInitializedAsync()
=> _credentials = await Store.ListAsync();
private async Task HandlePresentAsync(StoredCredentialSummary selected, PresentationRequest request)
{
if (!await Biometric.UnlockAsync("Confirm to present credential"))
return;
// Build the SD-JWT presentation directly via ISdJwtService — there is no
// server-side "build" endpoint; the server exposes CreateRequestAsync /
// FindMatchingCredentialsAsync / SubmitPresentationAsync on PresentationRequestService.
var compactToken = await Store.GetRawTokenAsync(selected.Id);
var presentation = await SdJwt.CreatePresentationAsync(
rawToken: compactToken,
claimsToDisclose: request.RequestedClaimNames,
holderKey: await Store.GetHolderKeyAsync(selected.Id),
audience: request.VerifierDid,
nonce: request.Nonce);
Navigation.NavigateTo($"/present/qr?token={Uri.EscapeDataString(presentation.Compact)}");
}
}
Platform registration:
- MAUI host →
MauiCredentialUiStore(wrapsMicrosoft.Maui.Storage.SecureStorage) +MauiBiometricGate(wrapsPlugin.Fingerprint) - WASM host →
IndexedDbCredentialUiStore+NoOpBiometricGate
See references/maui-ui.md for full component set, QR/deep-link flows, and Playwright test harness.
Common Pitfalls
- Do not use Data Integrity Proofs / JSON-LD canonicalisation. Sorcha chose SD-JWT VC.
DataIntegrityProof,eddsa-rdfc-2022, and RDF canonicalisation are not in the codebase and should not be added. - Do not hand-roll
publicKeyMultibase— callMulticodec.ToMultibasePublicKey(algorithm, keyBytes). Feature 093 exists because someone did this wrong before. - Do not use
Newtonsoft.Json. All VC serialisation isSystem.Text.JsonwithJsonDefaults.Apion the wire (seeCLAUDE.md§ Critical Pattern 4 — JsonSchema.Net expectsJsonElement). - Do not sign from the root wallet key. VC issuance uses the org's
KeyUsage.VCIssuancekey (Feature 083 slot 1) viaIIssuanceKeyService(Feature 120) — there is no"sorcha:vc-issuance"derivation-purpose string (that label does not exist in the codebase; key selection is byKeyUsage). - Do not put
HttpClientcalls insideCredentialVerifier. Revocation lookups go throughIRevocationChecker; the WASM-friendly in-memory implementation is what makes offline verification bundles (feature 079) possible. - Do not cache DID documents indefinitely. Validator key rotation (feature 086) must invalidate cached documents. Use
IMemoryCachewith aCancellationChangeTokendriven offIValidatorKeyCache.OnRotated. - Do not default
RevocationCheckPolicytoFailOpen— feature 093 madeFailClosedthe default for a reason. - Do not redesign the
CredentialIssuanceConfig/CredentialRequirement/CredentialPresentationtypes as records. They are mutable classes by deliberate choice forDataAnnotationsinterop.
See Also
- patterns — SD-JWT layout,
ISdJwtServiceusage, Multicodec encoding, DID document assembly - workflows — Issue, present, verify, revoke, resolve flows with the real API signatures
- maui-ui — MAUI Blazor credential wallet components, QR/deep-link flows, SecureStorage + biometric gate
Related Skills
- cryptography — Ed25519 / P-256 signing primitives under every proof
- nbitcoin — HD derivation paths, including the VC issuance purpose node
- blueprint-builder —
credentialIssuance/credentialRequirementsaction fields - blazor — Component structure for
InteractiveServer+InteractiveWebAssembly - sorcha-ui — Credential wallet pages and Playwright tests
- walkthrough-builder —
SelfBuildHousewalkthrough exercises cross-register VCs
Documentation Resources
W3C and IETF specs are authoritative. Fetch with Context7 — do not rely on blog posts.
How to use Context7:
- Use
mcp__context7__resolve-library-idto search for the spec (e.g."sd-jwt vc","w3c did core") - Prefer website documentation (IDs starting with
/websites/) — spec pages are ground truth - Query with
mcp__context7__query-docsusing the resolved library ID
Library IDs:
/websites/w3c_tr_vc-data-model-2_0— VC Data Model 2.0/websites/w3c_tr_did-core— DID Core 1.0/websites/w3c_tr_vc-bitstring-status-list— Bitstring Status List/websites/datatracker_ietf_org_doc_html_draft-ietf-oauth-sd-jwt-vc— SD-JWT VC profile/websites/datatracker_ietf_org_doc_html_rfc9901— RFC 9901 (SD-JWT)
Recommended Queries:
- "sd-jwt vc disclosure paths holder key binding"
- "verifiable credential data model 2.0 required properties"
- "did core resolution algorithm"
- "bitstring status list credential status"
- "multicodec varint public key ed25519"
NuGet landscape (2026):
IdentityModel— useful for OAuth/OIDC token primitives; does not implement SD-JWT VCDIF.DIDCore— partial DID Core support; watch for .NET 10 compatibilityJose-JWT— JWS/JWT primitives; Sorcha uses it underSdJwtServiceSimpleBase— base58btc encoding, used byMulticodec- No complete SD-JWT VC implementation exists —
Sorcha.Cryptography.SdJwt+Sorcha.Blueprint.Engine/Credentials/is the in-house solution and must track the spec directly