From 70d87584a89984a9db78bf824814719112c7aaa4 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Tue, 24 Feb 2026 16:07:48 +0100 Subject: [PATCH 01/78] feat(delegation): add delegated signing evidence spec and implementation - Add delegation challenge encoding spec (v2.0) with OID4VP alignment - Implement Python delegation module with TransactionData dataclass - Add 48 unit tests for challenge creation, parsing, and validation - Add did:web vs did:webs evaluation document - Download reference specs (OID4VP, did:web, did:webs, KERI) for offline access - Update documentation structure with guides and specs sections - Add contributing guide and architecture overview The delegated signing model enables users to authorize blockchain transactions through any VC wallet, with a signing service executing on their behalf. Challenge format uses hash-only approach for QR code compatibility while maintaining full auditability. BREAKING CHANGE: Evidence format changes from nonce-based to proof.challenge with ABNF-defined structure. Signed-off-by: Carlo van Driesten --- .github/copilot-instructions.md | 12 +- .gitmodules | 1 - AGENTS.md | 2 +- CLAUDE.md | 2 +- docs/api/python/index.md | 12 +- docs/api/typescript/index.md | 4 +- docs/{README.md => architecture.md} | 0 docs/contributing.md | 153 + docs/getting-started/quickstart.md | 4 +- docs/guide/delegated-signing.md | 290 ++ docs/guide/evidence.md | 213 ++ docs/index.md | 1 - docs/specs/delegation-challenge-encoding.md | 857 +++++ docs/specs/did-method-evaluation.md | 338 ++ docs/specs/references/README.md | 66 + docs/specs/references/did-web-method.txt | 429 +++ docs/specs/references/did-webs-spec.md | 1809 +++++++++ docs/specs/references/keri-draft.md | 1601 ++++++++ docs/specs/references/oid4vp-1.0.txt | 3834 +++++++++++++++++++ linkml/harbour.yaml | 85 +- mkdocs.yml | 15 +- src/python/harbour/delegation.py | 511 +++ src/python/harbour/sd_jwt_vp.py | 490 +++ tests/python/harbour/test_delegation.py | 820 ++++ tests/python/harbour/test_sd_jwt_vp.py | 581 +++ 25 files changed, 12108 insertions(+), 22 deletions(-) rename docs/{README.md => architecture.md} (100%) create mode 100644 docs/contributing.md create mode 100644 docs/guide/delegated-signing.md create mode 100644 docs/guide/evidence.md create mode 100644 docs/specs/delegation-challenge-encoding.md create mode 100644 docs/specs/did-method-evaluation.md create mode 100644 docs/specs/references/README.md create mode 100644 docs/specs/references/did-web-method.txt create mode 100644 docs/specs/references/did-webs-spec.md create mode 100644 docs/specs/references/keri-draft.md create mode 100644 docs/specs/references/oid4vp-1.0.txt create mode 100644 src/python/harbour/delegation.py create mode 100644 src/python/harbour/sd_jwt_vp.py create mode 100644 tests/python/harbour/test_delegation.py create mode 100644 tests/python/harbour/test_sd_jwt_vp.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d2c179b..9dcb20a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -33,11 +33,12 @@ make test-cov Read these BEFORE making changes: -| Topic | File | -| ------------------ | ---------------------- | -| Agent instructions | [../AGENTS.md](../AGENTS.md) | -| Claude guidance | [../CLAUDE.md](../CLAUDE.md) | -| Documentation | [../README.md](../README.md) | +| Topic | File | +| ------------------ | ----------------------------------------- | +| Agent instructions | [AGENTS.md](../AGENTS.md) | +| Claude guidance | [CLAUDE.md](../CLAUDE.md) | +| Documentation | [README.md](../README.md) | +| Architecture | [architecture.md](../docs/architecture.md)| ## Core Principles @@ -110,6 +111,7 @@ When instructed to prepare a commit or PR, **do not commit directly**. Instead: - `.playground/pr-description.md` — PR description The human operator will review these files and either: + - Use them to manually commit/push and create a PR, or - Use automated tooling with signed commits (`git commit -s -S`) diff --git a/.gitmodules b/.gitmodules index c8a0e2e..8169ada 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,6 @@ [submodule "submodules/ontology-management-base"] path = submodules/ontology-management-base url = https://github.com/ASCS-eV/ontology-management-base.git - branch = feat/unified-catalog-validation shallow = true [submodule "submodules/w3id.org"] diff --git a/AGENTS.md b/AGENTS.md index f87e328..0c9c86f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ Read these before making changes; they are authoritative for repo workflows. | ------------------ | ------------------------------------------------------------------ | | Agent instructions | [.github/copilot-instructions.md](.github/copilot-instructions.md) | | Documentation | [README.md](README.md) | -| Docs | [docs/README.md](docs/README.md) | +| Architecture | [docs/architecture.md](docs/architecture.md) | ## Project Structure diff --git a/CLAUDE.md b/CLAUDE.md index 988a3a8..8c5a23e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -166,7 +166,7 @@ When instructed to prepare a commit or PR, **do not commit directly**. Create th |-------|------| | Agent instructions | [AGENTS.md](AGENTS.md) | | Copilot instructions | [.github/copilot-instructions.md](.github/copilot-instructions.md) | -| Documentation | [docs/README.md](docs/README.md) | +| Architecture | [docs/architecture.md](docs/architecture.md) | | ADRs | [docs/decisions/](docs/decisions/) | ## Common Mistakes to Avoid diff --git a/docs/api/python/index.md b/docs/api/python/index.md index 969be87..51e9599 100644 --- a/docs/api/python/index.md +++ b/docs/api/python/index.md @@ -6,12 +6,12 @@ This section documents the Python API for Harbour Credentials. | Module | Description | |--------|-------------| -| [`harbour.keys`](keys.md) | Key generation and DID encoding | -| [`harbour.signer`](signer.md) | JWT signing | -| [`harbour.verifier`](verifier.md) | JWT verification | -| [`harbour.sd_jwt`](sd_jwt.md) | SD-JWT selective disclosure | -| [`harbour.kb_jwt`](kb_jwt.md) | Key Binding JWT | -| [`harbour.x509`](x509.md) | X.509 certificates | +| `harbour.keys` | Key generation and DID encoding | +| `harbour.signer` | JWT signing | +| `harbour.verifier` | JWT verification | +| `harbour.sd_jwt` | SD-JWT selective disclosure | +| `harbour.kb_jwt` | Key Binding JWT | +| `harbour.x509` | X.509 certificates | ## Quick Import Reference diff --git a/docs/api/typescript/index.md b/docs/api/typescript/index.md index 6e4570a..4e454eb 100644 --- a/docs/api/typescript/index.md +++ b/docs/api/typescript/index.md @@ -73,6 +73,4 @@ interface KbJwtOptions { ## Generated Documentation -Full TypeScript API documentation is generated with TypeDoc and available at: - -- [TypeDoc API Reference](./typedoc/index.html) +Full TypeScript API documentation can be generated with TypeDoc by running `yarn docs` in the TypeScript package directory. diff --git a/docs/README.md b/docs/architecture.md similarity index 100% rename from docs/README.md rename to docs/architecture.md diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..373f57c --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,153 @@ +# Contributing to Harbour Credentials + +Thank you for your interest in contributing to Harbour Credentials! + +## Getting Started + +1. **Fork and clone** the repository: + + ```bash + git clone --recurse-submodules https://github.com/YOUR_USERNAME/harbour-credentials.git + cd harbour-credentials + ``` + +2. **Set up the development environment**: + + ```bash + make setup + source .venv/bin/activate + ``` + +3. **Verify everything works**: + + ```bash + make test-all + make lint + ``` + +## Development Workflow + +### Branching + +- Create feature branches from `main` +- Use descriptive branch names: `feat/add-kb-jwt-support`, `fix/sd-jwt-verification` + +### Making Changes + +1. **Python code** lives in `src/python/harbour/` and `src/python/credentials/` +2. **TypeScript code** lives in `src/typescript/harbour/` +3. **Tests** live in `tests/` (see structure in README) +4. **Documentation** lives in `docs/` + +### Code Style + +#### Python + +- Python 3.12+ with type hints on public APIs +- Use `pathlib.Path` (not `os.path`) +- All modules must have `main()` with `argparse` and `--help` +- Run `make lint` and `make format` before committing + +#### TypeScript + +- TypeScript 5.x with strict mode +- Use `async/await` for crypto operations +- Export types alongside functions +- Run `make lint-ts` before committing + +### Testing + +```bash +# Run all tests +make test-all + +# Python only +make test + +# TypeScript only +make test-ts + +# Single Python test file +PYTHONPATH=src/python:$PYTHONPATH pytest tests/python/harbour/test_keys.py -v + +# Single TypeScript test +cd src/typescript/harbour && yarn vitest run --config vitest.config.ts ../../../tests/typescript/harbour/keys.test.ts +``` + +### Feature Parity + +When adding features, implement in **both** Python and TypeScript to maintain feature parity. Use consistent API naming: + +| Python (snake_case) | TypeScript (camelCase) | +|---------------------|------------------------| +| `generate_p256_keypair()` | `generateP256Keypair()` | +| `sign_vc_jose()` | `signVcJose()` | +| `verify_sd_jwt_vc()` | `verifySdJwtVc()` | + +## Commit Guidelines + +### Commit Message Format + +Use [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat(harbour): add KB-JWT verification +fix(sd-jwt): handle empty disclosure arrays +docs: update quickstart guide +test(interop): add cross-runtime signing test +chore: update dependencies +``` + +### Signing Commits + +All commits must be signed: + +```bash +git commit -s -S -m "feat(harbour): add feature" +``` + +- `-s` adds `Signed-off-by` line (DCO) +- `-S` adds GPG signature + +## Pull Requests + +### Before Submitting + +- [ ] All tests pass (`make test-all`) +- [ ] Linting passes (`make lint`) +- [ ] Documentation is updated if needed +- [ ] Commit messages follow conventional format +- [ ] Commits are signed (`-s -S`) + +### PR Description + +Include: + +- **Summary** of the changes +- **Testing** performed +- **Related issues** (e.g., `Closes #42`) + +## Architecture Decisions + +Major design decisions are documented in Architecture Decision Records (ADRs): + +- [ADR-001: VC Securing Mechanism](decisions/001-vc-securing-mechanism.md) +- [ADR-002: Dual Runtime Architecture](decisions/002-dual-runtime-architecture.md) +- [ADR-003: Canonicalization](decisions/003-canonicalization.md) +- [ADR-004: Key Management](decisions/004-key-management.md) + +When proposing significant changes, consider creating a new ADR. + +## Reporting Issues + +- **Bugs**: Include steps to reproduce, expected vs actual behavior, and environment details +- **Features**: Describe the use case and proposed solution +- **Security**: Report security vulnerabilities privately (do not create public issues) + +## Code of Conduct + +Be respectful and inclusive. We welcome contributors of all backgrounds and experience levels. + +## License + +By contributing, you agree that your contributions will be licensed under the EPL-2.0 License. diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index f51a274..e0ae952 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -122,6 +122,6 @@ This guide gets you signing and verifying credentials in minutes. ## Next Steps -- [Key Management](../guide/keys.md) — Advanced key operations -- [SD-JWT Guide](../guide/sd-jwt.md) — Selective disclosure in depth - [CLI Reference](../cli/index.md) — Command-line tools +- [Python API](../api/python/index.md) — Python API reference +- [TypeScript API](../api/typescript/index.md) — TypeScript API reference diff --git a/docs/guide/delegated-signing.md b/docs/guide/delegated-signing.md new file mode 100644 index 0000000..d738a38 --- /dev/null +++ b/docs/guide/delegated-signing.md @@ -0,0 +1,290 @@ +# Delegated Signing + +Harbour's delegated signing feature enables users to authorize blockchain transactions through **any** VC wallet, with a signing service executing on their behalf. This decouples wallet choice from blockchain capability. + +## Problem + +Traditional blockchain transactions require a wallet that can both: + +1. Hold Verifiable Credentials (for identity) +2. Sign blockchain transactions (for execution) + +Currently, only specialized wallets (like Altme) offer both capabilities. This creates vendor lock-in and limits user choice. + +## Solution + +Harbour separates these concerns: + +- **User's wallet**: Holds credentials, creates consent proofs (VPs) +- **Harbour signing service**: Executes blockchain transactions on behalf of users + +The key innovation is **cryptographic proof of consent** — the user's VP serves as auditable evidence that they authorized the transaction. + +## How It Works + +``` +User Signing Service Blockchain + │ │ │ + │ 1. Request transaction │ │ + │ ─────────────────────► │ │ + │ │ │ + │ 2. Consent request │ │ + │ ◄───────────────────── │ │ + │ (transaction details, │ │ + │ nonce) │ │ + │ │ │ + │ 3. Create SD-JWT VP │ │ + │ (consent proof with │ │ + │ redacted PII) │ │ + │ ─────────────────────► │ │ + │ │ │ + │ │ 4. Verify VP │ + │ │ ✓ Signature valid │ + │ │ ✓ Credential valid │ + │ │ ✓ Intent matches │ + │ │ │ + │ │ 5. Execute transaction │ + │ │ ─────────────────────► │ + │ │ │ + │ │ 6. Store VP as evidence │ + │ │ (for audit) │ + │ │ │ +``` + +## User Setup + +### 1. Harbour Credential + +The user needs a Harbour credential (e.g., `NaturalPersonCredential`) issued as an **SD-JWT-VC** with disclosable claims: + +```json +{ + "type": ["VerifiableCredential", "harbour:NaturalPersonCredential"], + "issuer": "did:web:issuer.example.com", + "credentialSubject": { + "id": "did:web:carlo.simpulse.io", + "type": "harbour:NaturalPerson", + "name": "Carlo Rossi", // ← Disclosable (PII) + "email": "carlo@bmw.de", // ← Disclosable (PII) + "memberOf": "did:web:bmw.gaiax.de" + } +} +``` + +### 2. DID Document + +The user's DID document (`did:web:carlo.simpulse.io`) must contain a verification method with their P-256 public key: + +```json +{ + "@context": ["https://www.w3.org/ns/did/v1"], + "id": "did:web:carlo.simpulse.io", + "verificationMethod": [{ + "id": "did:web:carlo.simpulse.io#key-1", + "type": "JsonWebKey2020", + "controller": "did:web:carlo.simpulse.io", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "...", + "y": "..." + } + }], + "authentication": ["did:web:carlo.simpulse.io#key-1"] +} +``` + +## Creating the Consent VP + +When the signing service requests consent, the user creates an **SD-JWT VP** with: + +1. **Selective disclosure**: Only non-PII claims disclosed +2. **Evidence**: Transaction intent proving what was consented to +3. **Signature**: Signed with the user's P-256 key + +### Python Example + +```python +from harbour.sd_jwt_vp import issue_sd_jwt_vp + +# User's SD-JWT-VC (with all disclosures) +sd_jwt_vc = "eyJ...~disclosure1~disclosure2~..." + +# Transaction intent (what the user is consenting to) +evidence = [{ + "type": "harbour:DelegatedSignatureEvidence", + "transactionIntent": { + "type": "harbour:TransactionIntent", + "actionType": "purchase", + "actionReference": "urn:uuid:tx-12345", + "description": "Purchase 'Weather Data 2024' for €500", + "consentTimestamp": "2024-01-15T10:30:00Z", + "nonce": "abc123xyz" + }, + "delegatedTo": "did:web:signing-service.harbour.io" +}] + +# Create VP with selective disclosure (redact PII) +sd_jwt_vp = issue_sd_jwt_vp( + sd_jwt_vc, + holder_private_key, + disclosures=["memberOf"], # Only disclose non-PII claims + evidence=evidence, + nonce="abc123xyz", + audience="did:web:signing-service.harbour.io" +) +``` + +### TypeScript Example + +```typescript +import { issueSdJwtVp } from '@reachhaven/harbour-credentials'; + +const sdJwtVp = await issueSdJwtVp(sdJwtVc, holderPrivateKey, { + disclosures: ['memberOf'], + evidence: [{ + type: 'harbour:DelegatedSignatureEvidence', + transactionIntent: { + type: 'harbour:TransactionIntent', + actionType: 'purchase', + actionReference: 'urn:uuid:tx-12345', + description: "Purchase 'Weather Data 2024' for €500", + consentTimestamp: '2024-01-15T10:30:00Z', + nonce: 'abc123xyz' + }, + delegatedTo: 'did:web:signing-service.harbour.io' + }], + nonce: 'abc123xyz', + audience: 'did:web:signing-service.harbour.io' +}); +``` + +## Verification + +The signing service verifies the VP before executing the transaction: + +```python +from harbour.sd_jwt_vp import verify_sd_jwt_vp + +result = verify_sd_jwt_vp( + sd_jwt_vp, + issuer_public_key, # From credential issuer's DID + holder_public_key, # From user's DID document + expected_nonce="abc123xyz", + expected_audience="did:web:signing-service.harbour.io" +) + +# Check transaction intent matches original request +assert result["evidence"][0]["transactionIntent"]["actionReference"] == "urn:uuid:tx-12345" + +# Check credential is still valid (CRSet) +# ... revocation check ... + +# All checks pass → execute transaction +``` + +## Privacy Model + +The SD-JWT VP enables **privacy-preserving audit**: + +| Data | Public Audit | Private Audit | +|------|--------------|---------------| +| Transaction intent | ✅ Visible | ✅ Visible | +| User DID | ✅ Visible | ✅ Visible | +| VP signature | ✅ Verifiable | ✅ Verifiable | +| Credential validity | ✅ Via CRSet | ✅ Via CRSet | +| User name | ❌ Redacted | ✅ Available | +| User email | ❌ Redacted | ✅ Available | + +**Public audit** proves: +> "The holder of `did:web:carlo.simpulse.io` consented to transaction `tx-12345` at `2024-01-15T10:30:00Z`" + +**Private audit** (with additional disclosures) proves: +> "Carlo Rossi (carlo@bmw.de), member of BMW, consented to..." + +## Security Considerations + +### Replay Protection + +The `nonce` in `TransactionIntent` prevents replay attacks: + +- Signing service generates unique nonce per request +- VP must contain matching nonce +- Nonce is single-use + +### Audience Binding + +The `audience` field ensures the VP was created for a specific verifier: + +```python +verify_sd_jwt_vp( + vp, + ..., + expected_audience="did:web:signing-service.harbour.io" +) +``` + +### Revocation Checking + +Before executing, verify the credential hasn't been revoked: + +```python +# Check CRSet entry +crset_entry = result["credential"]["credentialStatus"][0] +is_revoked = check_crset(crset_entry["id"]) +if is_revoked: + raise Error("Credential has been revoked") +``` + +### DID Document Verification + +Verify the VP signature matches the public key in the user's DID document: + +```python +# Resolve DID document +did_doc = resolve_did("did:web:carlo.simpulse.io") + +# Extract public key +public_key = did_doc["verificationMethod"][0]["publicKeyJwk"] + +# Verify VP was signed with this key +verify_sd_jwt_vp(vp, issuer_key, public_key_from_did_doc, ...) +``` + +## Use Cases + +### Data Marketplace + +User purchases dataset through blockchain: + +1. User browses marketplace, selects dataset +2. App requests consent: "Purchase 'Weather Data 2024' for €500?" +3. User creates consent VP with wallet +4. Harbour executes blockchain transaction +5. VP stored as purchase receipt/evidence + +### Contract Signing + +User signs legal contract: + +1. Contract platform prepares document +2. Requests signature: "Sign employment contract with BMW?" +3. User creates consent VP +4. Harbour records signature on blockchain +5. VP serves as proof of signing intent + +### Access Delegation + +User grants access to resource: + +1. Service requests access: "Grant read access to Project X?" +2. User creates consent VP +3. Harbour updates access control on blockchain +4. VP serves as access grant evidence + +## Related Documentation + +- [Evidence Types](evidence.md) — All Harbour evidence types +- [SD-JWT-VC](../api/python/index.md) — SD-JWT credential issuance +- [ADR-001: VC Securing Mechanism](../decisions/001-vc-securing-mechanism.md) — Why SD-JWT +- [ADR-004: Key Management](../decisions/004-key-management.md) — P-256 keys diff --git a/docs/guide/evidence.md b/docs/guide/evidence.md new file mode 100644 index 0000000..b9baa48 --- /dev/null +++ b/docs/guide/evidence.md @@ -0,0 +1,213 @@ +# Evidence in Harbour Credentials + +Evidence is a W3C VC Data Model concept that provides cryptographic proof of **how** an issuer verified claims or **why** a holder is authorized to perform an action. + +## What is Evidence? + +When a credential is issued or a presentation is made, the `evidence` field can contain supporting proof that: + +1. **For issuance**: Shows what the issuer relied upon to verify claims +2. **For presentations**: Shows why the holder is authorized to perform an action + +Evidence creates an **audit trail** — allowing third parties to verify not just *that* something happened, but *how* it was validated. + +## Harbour Evidence Types + +### EmailVerification + +Proves that an email address was verified before credential issuance. + +**Use case**: A `NaturalPersonCredential` includes evidence that the user's email was verified via an email verification service (e.g., Altme EmailPass). + +```json +{ + "type": "harbour:EmailVerification", + "verifiablePresentation": { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["VerifiablePresentation"], + "holder": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "verifiableCredential": [ + { + "type": ["VerifiableCredential"], + "issuer": "did:web:altme.io", + "credentialSubject": { + "type": "EmailPass", + "email": "alice@example.com" + } + } + ] + } +} +``` + +**What it proves**: The issuer verified the email address via a trusted email verification provider before issuing the credential. + +### IssuanceEvidence + +References a previously issued credential that served as the basis for the new credential. + +**Use case**: A `LegalPersonCredential` includes evidence of a prior credential from a notary attesting to the organization's registration. + +```json +{ + "type": "harbour:IssuanceEvidence", + "verifiablePresentation": { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["VerifiablePresentation"], + "holder": "did:web:participant.example.com", + "verifiableCredential": [ + { + "type": ["VerifiableCredential"], + "issuer": "did:web:notary.example.com", + "credentialSubject": { + "type": "gx:LegalPerson", + "gx:legalName": "Example Corporation GmbH", + "gx:registrationNumber": "DE123456789" + } + } + ] + } +} +``` + +**What it proves**: The issuer based the credential on a prior attestation from another trusted party (the notary). + +### DelegatedSignatureEvidence + +Proves user consent for a delegated signature operation. Used when a signing service executes transactions on behalf of users. + +**Use case**: A blockchain transaction record includes evidence that the user consented to the purchase. + +```json +{ + "type": "harbour:DelegatedSignatureEvidence", + "transactionIntent": { + "type": "harbour:TransactionIntent", + "actionType": "purchase", + "actionReference": "urn:uuid:tx-12345", + "description": "Purchase 'Weather Data 2024' for €500", + "consentTimestamp": "2024-01-15T10:30:00Z", + "nonce": "abc123xyz" + }, + "delegatedTo": "did:web:signing-service.harbour.io", + "verifiablePresentation": "" +} +``` + +**What it proves**: The user (identified by their DID) explicitly consented to the specific transaction at the specified time. + +See [Delegated Signing](delegated-signing.md) for the complete flow. + +## When to Use Each Type + +| Evidence Type | Use When | Example Scenario | +|--------------|----------|------------------| +| `EmailVerification` | Issuing credential that includes email claim | Onboarding a new user, verifying contact info | +| `IssuanceEvidence` | Basing credential on prior attestation | Trust anchor issuing based on notary credential | +| `DelegatedSignatureEvidence` | User consenting to delegated action | Blockchain purchase, contract signing | + +## Evidence Structure + +All evidence types inherit from the abstract `Evidence` class and share: + +```yaml +Evidence: + abstract: true + class_uri: cred:evidence + slots: + - type # Required: identifies the evidence type +``` + +Most evidence types include a `verifiablePresentation` slot containing a signed VP as proof. + +## Privacy Considerations + +Evidence often contains sensitive information. For privacy-preserving audit: + +1. **Use SD-JWT VPs**: Selectively disclose only necessary claims +2. **Redact PII**: Names, emails, etc. can be hidden while keeping DID visible +3. **Public vs. Private audit**: + - Public: Transaction intent + DID + signature validity + - Private: Full credential details with all claims + +## Verification + +When verifying credentials or presentations with evidence: + +1. **Verify the outer signature** (credential or VP) +2. **Verify each evidence VP signature** +3. **Check evidence issuer trust** (is the evidence issuer trusted?) +4. **Validate evidence freshness** (timestamps, nonces) +5. **Check revocation status** of evidence credentials + +```python +from harbour.verifier import verify_vc_jose + +# Verify outer credential +result = verify_vc_jose(credential_jwt, issuer_public_key) + +# Verify evidence VP +for evidence in result.get("evidence", []): + if "verifiablePresentation" in evidence: + vp = evidence["verifiablePresentation"] + # Verify VP signature... +``` + +## Adding Evidence to Credentials + +When issuing a credential with evidence: + +```python +credential = { + "@context": [...], + "type": ["VerifiableCredential", "harbour:NaturalPersonCredential"], + "issuer": "did:web:issuer.example.com", + "credentialSubject": {...}, + "evidence": [ + { + "type": "harbour:EmailVerification", + "verifiablePresentation": email_verification_vp_jwt + } + ] +} + +signed_vc = sign_vc_jose(credential, issuer_private_key) +``` + +## Schema Definition + +Evidence types are defined in `linkml/harbour.yaml`: + +```yaml +Evidence: + abstract: true + class_uri: cred:Evidence + slots: + - type + +EmailVerification: + is_a: Evidence + class_uri: harbour:EmailVerification + slots: + - verifiablePresentation + +IssuanceEvidence: + is_a: Evidence + class_uri: harbour:IssuanceEvidence + slots: + - verifiablePresentation + +DelegatedSignatureEvidence: + is_a: Evidence + class_uri: harbour:DelegatedSignatureEvidence + slots: + - verifiablePresentation + - transactionIntent + - delegatedTo +``` + +## Related Documentation + +- [Delegated Signing](delegated-signing.md) — Full delegated signing flow +- [SD-JWT-VC](../api/python/index.md) — Selective disclosure credentials +- [W3C VC Data Model — Evidence](https://www.w3.org/TR/vc-data-model-2.0/#evidence) diff --git a/docs/index.md b/docs/index.md index c5d301c..34fd4cc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -67,6 +67,5 @@ - [Installation](getting-started/installation.md) — Detailed setup instructions - [Quick Start](getting-started/quickstart.md) — Get up and running -- [User Guide](guide/keys.md) — In-depth usage guides - [CLI Reference](cli/index.md) — Command-line tools - [API Reference](api/python/index.md) — Python and TypeScript APIs diff --git a/docs/specs/delegation-challenge-encoding.md b/docs/specs/delegation-challenge-encoding.md new file mode 100644 index 0000000..2217fc8 --- /dev/null +++ b/docs/specs/delegation-challenge-encoding.md @@ -0,0 +1,857 @@ +# Harbour Delegated Signing Evidence Specification + +**Version**: 2.0.0 +**Status**: Draft +**Namespace**: `https://harbour.reachhaven.io/delegation/v2` + +--- + +## 1. Overview + +This specification defines how to bind a Verifiable Presentation (VP) to a specific transaction for delegated signing consent. The design: + +- **Aligns with OpenID4VP** `transaction_data` mechanism ([OID4VP §8.4](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#section-8.4)) +- **Uses only W3C standard fields** — no proprietary extensions +- **Supports QR code presentation** — challenge contains hash, full data stored separately +- **Enables auditability** — transaction details can be verified against hash + +### 1.1 Design Philosophy + +Following the OID4VP pattern: + +| Component | Purpose | Location | +|-----------|---------|----------| +| **Full transaction data** | Human review, business logic | Request body OR external reference | +| **Transaction data hash** | Cryptographic binding | `proof.challenge` (signed by holder) | +| **Verifier identity** | Trust anchor | `proof.domain` | +| **Replay protection** | Freshness | `proof.nonce` / timestamp in challenge | + +This separation is critical for QR code flows where the signed proof must be compact. + +--- + +## 2. Challenge Format + +### 2.1 Structure + +The `proof.challenge` field uses a compact, single-line format: + +``` + HARBOUR_DELEGATE +``` + +Where: +- `` is a unique identifier (hex string, min 8 chars) +- `HARBOUR_DELEGATE` is the action type identifier +- `` is the lowercase hex-encoded SHA-256 hash of the transaction data + +### 2.2 Example + +``` +da9b1009 HARBOUR_DELEGATE d0450062b3c4c9168ac8266f0806d62f5d95ed96894d5a9a0aaddf4298317eaa +``` + +This format is inspired by [simpulse-id-credentials](https://github.com/ASCS-eV/simpulse-id-credentials) which uses: +``` + ISSUE_PAYLOAD +``` + +### 2.3 ABNF Grammar (RFC 5234) + +```abnf +; ============================================================ +; Harbour Delegation Challenge - ABNF Grammar +; RFC 5234 compliant +; ============================================================ + +; --- Top-level production --- +delegation-challenge = nonce SP action-type SP hash + +; --- Components --- +nonce = 8*16HEXDIG ; e.g., "da9b1009" +action-type = "HARBOUR_DELEGATE" ; fixed identifier +hash = 64HEXDIG ; SHA-256 (32 bytes = 64 hex chars) + +; --- Core rules (RFC 5234 Appendix B.1) --- +SP = %x20 ; space +HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F" + / "a" / "b" / "c" / "d" / "e" / "f" +DIGIT = %x30-39 ; 0-9 +``` + +--- + +## 3. Transaction Data Object + +The full transaction details are stored separately (in the VP body, request, or external reference). The hash in the challenge is computed over this JSON object. + +This structure aligns with [OID4VP §5.1 `transaction_data`](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#section-5.1) parameter. + +### 3.1 Structure + +```json +{ + "type": "harbour_delegate:", + "credential_ids": [""], + "transaction_data_hashes_alg": ["sha-256"], + "nonce": "", + "iat": , + "exp": , + "txn": { + // Action-specific transaction details + } +} +``` + +### 3.2 Required Fields (OID4VP Compliant) + +| Field | Type | OID4VP | Description | +|-------|------|--------|-------------| +| `type` | string | REQUIRED | Transaction data type identifier. Format: `harbour_delegate:` | +| `credential_ids` | string[] | REQUIRED | References to DCQL Credential Query `id` fields that can authorize this transaction | +| `nonce` | string | Extension | Unique identifier for replay protection (same as in challenge) | +| `iat` | number | Extension | Issued-at Unix timestamp (seconds since epoch) | + +### 3.3 Optional Fields + +| Field | Type | Description | +|-------|------|-------------| +| `transaction_data_hashes_alg` | string[] | Hash algorithms supported. Default: `["sha-256"]` | +| `exp` | number | Expiration Unix timestamp | +| `txn` | object | Action-specific transaction details (see §3.4) | +| `description` | string | Human-readable description for consent display | + +### 3.4 Transaction Details (`txn`) by Action Type + +| Action Type | `txn` Fields | +|-------------|--------------| +| `harbour_delegate:blockchain.transfer` | `chain`, `contract`, `recipient`, `amount`, `token` | +| `harbour_delegate:blockchain.execute` | `chain`, `contract`, `method`, `params`, `value` | +| `harbour_delegate:data.purchase` | `assetId`, `price`, `currency`, `marketplace` | +| `harbour_delegate:contract.sign` | `documentHash`, `documentUri`, `parties` | +| `harbour_delegate:credential.issue` | `credentialType`, `subject`, `claims` | + +### 3.5 Example Transaction Data + +```json +{ + "type": "harbour_delegate:data.purchase", + "credential_ids": ["simpulse_id"], + "transaction_data_hashes_alg": ["sha-256"], + "nonce": "da9b1009", + "iat": 1771934400, + "exp": 1771935300, + "description": "Purchase sensor data package from BMW", + "txn": { + "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "price": "100", + "currency": "ENVITED", + "marketplace": "did:web:dataspace.envited.io" + } +} +``` + +### 3.5 Computing the Hash + +```python +import hashlib +import json + +def compute_transaction_hash(transaction_data: dict) -> str: + """Compute SHA-256 hash of transaction data. + + Uses JSON canonical form: sorted keys, no whitespace. + """ + canonical = json.dumps(transaction_data, sort_keys=True, separators=(',', ':')) + return hashlib.sha256(canonical.encode('utf-8')).hexdigest() +``` + +The resulting challenge: +``` +da9b1009 HARBOUR_DELEGATE d0450062b3c4c9168ac8266f0806d62f5d95ed96894d5a9a0aaddf4298317eaa +``` + +--- + +## 4. VP Evidence Structure (W3C VC 2.0 Compliant) + +The delegated consent is captured as `evidence` in a Verifiable Credential or directly as the VP. This follows the pattern from [simpulse-id-credentials](https://github.com/ASCS-eV/simpulse-id-credentials/pull/24). + +### 4.1 Evidence with Embedded VP + +```json +{ + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["VerifiableCredential"], + "issuer": "did:web:harbour.signing-service.example.com", + "validFrom": "2026-02-24T12:00:00Z", + "credentialSubject": { + "id": "did:web:user.example.com" + }, + "evidence": [{ + "type": ["CredentialEvidence"], + "verifiablePresentation": { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["VerifiablePresentation"], + "holder": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH", + "verifiableCredential": [ + "" + ], + "proof": { + "type": "DataIntegrityProof", + "cryptosuite": "ecdsa-rdfc-2019", + "proofPurpose": "authentication", + "challenge": "da9b1009 HARBOUR_DELEGATE d0450062b3c4c9168ac8266f0806d62f5d95ed96894d5a9a0aaddf4298317eaa", + "domain": "did:web:harbour.signing-service.example.com", + "verificationMethod": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH#z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH", + "created": "2026-02-24T12:00:05Z", + "proofValue": "z5vgFc..." + } + } + }] +} +``` + +### 4.2 Key Fields Used (All Standard W3C) + +| Field | Vocabulary | Purpose | +|-------|------------|---------| +| `evidence` | [cred:evidence](https://www.w3.org/ns/credentials#evidence) | Links VP to credential | +| `proof.challenge` | [sec:challenge](https://w3id.org/security#challenge) | Transaction hash binding | +| `proof.domain` | [sec:domain](https://w3id.org/security#domain) | Signing service identity | +| `proof.nonce` | [sec:nonce](https://w3id.org/security#nonce) | Replay protection | +| `verifiablePresentation` | [cred:VerifiablePresentation](https://www.w3.org/ns/credentials#VerifiablePresentation) | Container for consent | + +### 4.3 Transaction Data Location + +The full transaction data object (§3) can be stored in one of: + +1. **VP `evidence[].transactionData`** — Inline (increases VP size) +2. **External reference** — VP contains hash, full data at `ref` URL +3. **Request context** — OID4VP `transaction_data` parameter (recommended) + +For auditability, the signing service MUST store the full transaction data and provide it on request. + +--- + +## 5. OID4VP Compatibility + +This specification is designed for seamless integration with [OpenID for Verifiable Presentations (OID4VP)](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html). + +### 5.1 Request Flow + +``` +┌─────────┐ ┌─────────┐ ┌─────────┐ +│ Verifier│ │ Wallet │ │ Signing │ +│(Service)│ │ (User) │ │ Service │ +└────┬────┘ └────┬────┘ └────┬────┘ + │ │ │ + │ Authorization Request │ │ + │ (transaction_data param) │ │ + │─────────────────────────────>│ │ + │ │ │ + │ │ Display transaction │ + │ │ for user consent │ + │ │ │ + │ │ User approves │ + │ │ │ + │ VP with KB-JWT │ │ + │ (transaction_data_hashes) │ │ + │<─────────────────────────────│ │ + │ │ │ + │ │ Execute transaction │ + │ │ with VP as evidence │ + │ │─────────────────────────────>│ + │ │ │ +``` + +### 5.2 OID4VP `transaction_data` Request Parameter + +```json +{ + "type": "harbour_delegated_signing", + "credential_ids": ["user_identity_credential"], + "transaction_data_hashes_alg": ["sha-256"], + "action": "data.purchase", + "transaction": { + "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "price": "100", + "currency": "ENVITED" + } +} +``` + +### 5.3 SD-JWT VC Key Binding JWT Response + +Per OID4VP Appendix B.3.3, the KB-JWT includes: + +```json +{ + "nonce": "n-0S6_WzA2Mj", + "aud": "did:web:harbour.signing-service.example.com", + "iat": 1709838604, + "sd_hash": "Dy-RYwZfaaoC3inJbLslgPvMp09bH-clYP_3qbRqtW4", + "transaction_data_hashes": ["d0450062b3c4c9168ac8266f0806d62f5d95ed96894d5a9a0aaddf4298317eaa"], + "transaction_data_hashes_alg": "sha-256" +} +``` + +### 5.4 Dual Support + +Our challenge format supports both: + +1. **OID4VP flow** — Hash in `transaction_data_hashes` (KB-JWT claim) +2. **Direct VP flow** — Hash in `proof.challenge` (W3C proof) + +The same hash can appear in both locations for maximum compatibility. + +--- + +## 6. Verification Requirements + +A verifier (signing service) MUST: + +1. **Parse the challenge** — Extract nonce, action type, and hash +2. **Retrieve transaction data** — From request context, cache, or external reference +3. **Verify hash** — Recompute SHA-256 of transaction data, compare to challenge hash +4. **Check nonce uniqueness** — Reject if nonce was previously used +5. **Validate timestamp** — Transaction timestamp within acceptable window (default: 5 minutes) +6. **Verify holder identity** — VP signature matches credential subject +7. **Check credential status** — Verify credential not revoked (CRL, status list) +8. **Validate domain** — `proof.domain` matches signing service DID + +--- + +## 7. Security Considerations + +### 7.1 Replay Protection + +- The `nonce` MUST be cryptographically random (min 64 bits / 8 hex chars) +- Verifiers MUST maintain a nonce registry and reject duplicates +- The transaction timestamp provides additional freshness guarantee + +### 7.2 Timestamp Validation + +- Accept timestamps within a configurable window (default: 5 minutes) +- Reject future timestamps beyond 1 minute clock skew allowance + +### 7.3 Hash Integrity + +- SHA-256 provides collision resistance +- The hash is signed as part of the VP proof +- Any modification to transaction data invalidates the hash match + +### 7.4 Selective Disclosure + +- SD-JWT VC allows redacting PII while maintaining signature validity +- The evidence VP can contain an SD-JWT with only non-PII claims disclosed +- This enables public audit without revealing holder identity + +--- + +## 8. Implementation + +### 8.1 Python + +```python +import hashlib +import json +import secrets +from datetime import datetime, timezone +from dataclasses import dataclass, field, asdict +from typing import Any + + +@dataclass +class TransactionData: + """Full transaction data object.""" + action: str + timestamp: str + nonce: str + transaction: dict[str, Any] + type: str = "HarbourDelegatedTransaction" + version: str = "1.0" + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict: + return asdict(self) + + def compute_hash(self) -> str: + """Compute SHA-256 hash of canonical JSON representation.""" + canonical = json.dumps(self.to_dict(), sort_keys=True, separators=(',', ':')) + return hashlib.sha256(canonical.encode('utf-8')).hexdigest() + + +def create_delegation_challenge( + transaction_data: TransactionData, +) -> str: + """Create a Harbour delegation challenge string. + + Format: HARBOUR_DELEGATE + """ + tx_hash = transaction_data.compute_hash() + return f"{transaction_data.nonce} HARBOUR_DELEGATE {tx_hash}" + + +def parse_delegation_challenge(challenge: str) -> tuple[str, str, str]: + """Parse a Harbour delegation challenge string. + + Returns: + Tuple of (nonce, action_type, hash) + """ + parts = challenge.split(' ') + if len(parts) != 3: + raise ValueError(f"Invalid challenge format: expected 3 parts, got {len(parts)}") + + nonce, action_type, tx_hash = parts + + if action_type != "HARBOUR_DELEGATE": + raise ValueError(f"Invalid action type: {action_type}") + + if len(tx_hash) != 64: + raise ValueError(f"Invalid hash length: expected 64, got {len(tx_hash)}") + + return nonce, action_type, tx_hash + + +def verify_challenge( + challenge: str, + transaction_data: TransactionData, +) -> bool: + """Verify that a challenge matches transaction data. + + Returns: + True if the hash in the challenge matches the transaction data + """ + nonce, _, challenge_hash = parse_delegation_challenge(challenge) + + if nonce != transaction_data.nonce: + return False + + computed_hash = transaction_data.compute_hash() + return challenge_hash == computed_hash + + +# Example usage +if __name__ == "__main__": + tx = TransactionData( + action="data.purchase", + timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + nonce=secrets.token_hex(4), # 8 hex chars + transaction={ + "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "price": "100", + "currency": "ENVITED", + }, + metadata={"description": "Purchase sensor data package"}, + ) + + challenge = create_delegation_challenge(tx) + print(f"Challenge: {challenge}") + print(f"Valid: {verify_challenge(challenge, tx)}") +``` + +### 8.2 TypeScript + +```typescript +import { createHash, randomBytes } from 'crypto'; + +interface TransactionData { + type: 'HarbourDelegatedTransaction'; + version: '1.0'; + action: string; + timestamp: string; + nonce: string; + transaction: Record; + metadata?: Record; +} + +function computeTransactionHash(data: TransactionData): string { + const canonical = JSON.stringify(data, Object.keys(data).sort()); + return createHash('sha256').update(canonical).digest('hex'); +} + +function createDelegationChallenge(data: TransactionData): string { + const hash = computeTransactionHash(data); + return `${data.nonce} HARBOUR_DELEGATE ${hash}`; +} + +function parseDelegationChallenge(challenge: string): { + nonce: string; + actionType: string; + hash: string; +} { + const parts = challenge.split(' '); + if (parts.length !== 3) { + throw new Error(`Invalid challenge format: expected 3 parts, got ${parts.length}`); + } + + const [nonce, actionType, hash] = parts; + + if (actionType !== 'HARBOUR_DELEGATE') { + throw new Error(`Invalid action type: ${actionType}`); + } + + if (hash.length !== 64) { + throw new Error(`Invalid hash length: expected 64, got ${hash.length}`); + } + + return { nonce, actionType, hash }; +} + +function verifyChallenge(challenge: string, data: TransactionData): boolean { + const { nonce, hash: challengeHash } = parseDelegationChallenge(challenge); + + if (nonce !== data.nonce) { + return false; + } + + const computedHash = computeTransactionHash(data); + return challengeHash === computedHash; +} + +// Example usage +const tx: TransactionData = { + type: 'HarbourDelegatedTransaction', + version: '1.0', + action: 'data.purchase', + timestamp: new Date().toISOString(), + nonce: randomBytes(4).toString('hex'), + transaction: { + assetId: 'urn:uuid:550e8400-e29b-41d4-a716-446655440000', + price: '100', + currency: 'ENVITED', + }, + metadata: { description: 'Purchase sensor data package' }, +}; + +console.log('Challenge:', createDelegationChallenge(tx)); +console.log('Valid:', verifyChallenge(createDelegationChallenge(tx), tx)); +``` + +--- + +## 9. Human-Readable Display + +Following the design philosophy of [SIWE (EIP-4361)](https://eips.ethereum.org/EIPS/eip-4361), transaction data SHOULD be rendered in a human-readable format when presented to users for consent. + +### 9.1 Display Format + +``` +╔══════════════════════════════════════════════════════════════╗ +║ Harbour Signing Service requests your authorization ║ +╠══════════════════════════════════════════════════════════════╣ +║ ║ +║ Action: Purchase data asset ║ +║ Asset: urn:uuid:550e8400-e29b-41d4-a716-44665544... ║ +║ Amount: 100 ENVITED ║ +║ ║ +╠══════════════════════════════════════════════════════════════╣ +║ Service: did:web:harbour.signing-service.example.com ║ +║ Nonce: da9b1009 ║ +║ Time: 2026-02-24 12:00:00 UTC ║ +╚══════════════════════════════════════════════════════════════╝ +``` + +### 9.2 Display Requirements + +Wallet/application implementations SHOULD: + +1. **Show all transaction fields**: action, transaction details, service, nonce, timestamp +2. **Use human-friendly labels** (e.g., "Purchase data asset" not "data.purchase") +3. **Format timestamps** in user's local timezone with clear UTC indication +4. **Truncate long values** (e.g., UUIDs) with ellipsis, showing full value on hover/tap +5. **Show the hash** for advanced users (collapsed by default) +6. **Require explicit consent** (button click, not auto-sign) + +### 9.3 Action Labels + +| Action Code | Human Label | +|-------------|-------------| +| `blockchain.transfer` | Transfer tokens | +| `blockchain.approve` | Approve token spending | +| `blockchain.execute` | Execute smart contract | +| `contract.sign` | Sign contract | +| `contract.accept` | Accept agreement | +| `data.purchase` | Purchase data asset | +| `data.share` | Share data | +| `credential.issue` | Issue credential | +| `credential.present` | Present credential | + +### 9.4 Python Display Renderer + +```python +ACTION_LABELS = { + "blockchain.transfer": "Transfer tokens", + "blockchain.approve": "Approve token spending", + "blockchain.execute": "Execute smart contract", + "contract.sign": "Sign contract", + "contract.accept": "Accept agreement", + "data.purchase": "Purchase data asset", + "data.share": "Share data", + "credential.issue": "Issue credential", + "credential.present": "Present credential", +} + +def render_transaction_display( + transaction_data: TransactionData, + service_name: str = "Harbour Signing Service" +) -> str: + """Render transaction data for human-readable display. + + Args: + transaction_data: The full transaction data object + service_name: Human-friendly name for the signing service + + Returns: + Multi-line string suitable for display to user + """ + action = transaction_data.action + action_label = ACTION_LABELS.get(action, action.replace(".", " ").title()) + + lines = [ + f"{service_name} requests your authorization", + "─" * 50, + "", + f" Action: {action_label}", + ] + + # Add transaction-specific fields + for key, value in transaction_data.transaction.items(): + display_key = key.replace("_", " ").title() + display_value = str(value) + if len(display_value) > 40: + display_value = display_value[:37] + "..." + lines.append(f" {display_key}: {display_value}") + + lines.extend([ + "", + "─" * 50, + f" Nonce: {transaction_data.nonce}", + f" Time: {transaction_data.timestamp}", + ]) + + if transaction_data.metadata.get("expiresAt"): + lines.append(f" Expires: {transaction_data.metadata['expiresAt']}") + + return "\n".join(lines) +``` + +### 9.5 TypeScript Display Renderer + +```typescript +const ACTION_LABELS: Record = { + 'blockchain.transfer': 'Transfer tokens', + 'blockchain.approve': 'Approve token spending', + 'blockchain.execute': 'Execute smart contract', + 'contract.sign': 'Sign contract', + 'contract.accept': 'Accept agreement', + 'data.purchase': 'Purchase data asset', + 'data.share': 'Share data', + 'credential.issue': 'Issue credential', + 'credential.present': 'Present credential', +}; + +function renderTransactionDisplay( + data: TransactionData, + serviceName = 'Harbour Signing Service' +): string { + const actionLabel = ACTION_LABELS[data.action] ?? + data.action.replace('.', ' ').replace(/\b\w/g, c => c.toUpperCase()); + + const lines: string[] = [ + `${serviceName} requests your authorization`, + '─'.repeat(50), + '', + ` Action: ${actionLabel}`, + ]; + + for (const [key, value] of Object.entries(data.transaction)) { + const displayKey = key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); + let displayValue = String(value); + if (displayValue.length > 40) { + displayValue = displayValue.slice(0, 37) + '...'; + } + lines.push(` ${displayKey}: ${displayValue}`); + } + + lines.push( + '', + '─'.repeat(50), + ` Nonce: ${data.nonce}`, + ` Time: ${data.timestamp}`, + ); + + if (data.metadata?.expiresAt) { + lines.push(` Expires: ${data.metadata.expiresAt}`); + } + + return lines.join('\n'); +} +``` + +--- + +## 10. Examples + +### 10.1 Data Purchase Transaction + +**Transaction Data:** +```json +{ + "type": "HarbourDelegatedTransaction", + "version": "1.0", + "action": "data.purchase", + "timestamp": "2026-02-24T12:00:00Z", + "nonce": "da9b1009", + "transaction": { + "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "price": "100", + "currency": "ENVITED", + "marketplace": "did:web:dataspace.envited.io" + } +} +``` + +**Challenge:** +``` +da9b1009 HARBOUR_DELEGATE d0450062b3c4c9168ac8266f0806d62f5d95ed96894d5a9a0aaddf4298317eaa +``` + +### 10.2 Blockchain Transfer Transaction + +**Transaction Data:** +```json +{ + "type": "HarbourDelegatedTransaction", + "version": "1.0", + "action": "blockchain.transfer", + "timestamp": "2026-02-24T12:30:00Z", + "nonce": "ab12cd34", + "transaction": { + "chain": "eip155:42793", + "contract": "0x1234567890abcdef1234567890abcdef12345678", + "recipient": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", + "amount": "1000000000000000000", + "token": "ENVITED" + } +} +``` + +**Challenge:** +``` +ab12cd34 HARBOUR_DELEGATE 7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b +``` + +### 10.3 Contract Signature Transaction + +**Transaction Data:** +```json +{ + "type": "HarbourDelegatedTransaction", + "version": "1.0", + "action": "contract.sign", + "timestamp": "2026-02-24T13:00:00Z", + "nonce": "ef567890", + "transaction": { + "documentHash": "sha256:abc123def456...", + "documentUri": "https://contracts.example.com/abc123", + "parties": ["did:web:alice.example", "did:web:bob.example"] + }, + "metadata": { + "expiresAt": "2026-02-24T13:15:00Z" + } +} +``` + +--- + +## 11. Relationship to W3C Standards + +This encoding is used within **standard W3C fields**: + +| W3C Field | Purpose in This Spec | +|-----------|---------------------| +| `proof.challenge` | Contains ` HARBOUR_DELEGATE ` | +| `proof.domain` | Signing service DID | +| `proof.nonce` | Additional replay protection (optional) | +| `evidence` | Contains the embedded VP with consent | + +The challenge field is: + +- Part of the VP proof (signed by holder) +- Universally supported by VC wallets +- Immutable once signed + +--- + +## 12. Relationship to OpenID4VP + +This specification aligns with [OID4VP Transaction Data (§8.4)](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html#section-8.4): + +| OID4VP Concept | Harbour Delegation Equivalent | +|----------------|-------------------------------| +| `transaction_data` request param | Transaction Data Object (§3) | +| `transaction_data.type` | `"harbour_delegated_signing"` | +| `transaction_data_hashes` in KB-JWT | Same hash as in `proof.challenge` | +| `transaction_data_hashes_alg` | `"sha-256"` | + +### Integration Example + +OID4VP authorization request: +```json +{ + "response_type": "vp_token", + "client_id": "did:web:harbour.signing-service.example.com", + "nonce": "n-0S6_WzA2Mj", + "transaction_data": [{ + "type": "harbour_delegated_signing", + "credential_ids": ["simpulse_id"], + "transaction_data_hashes_alg": ["sha-256"], + "transaction": { + "action": "data.purchase", + "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "price": "100", + "currency": "ENVITED" + } + }] +} +``` + +The wallet computes the hash and includes it in the KB-JWT `transaction_data_hashes` claim. + +--- + +## 13. Relationship to SIWE (EIP-4361) + +This specification draws design inspiration from [Sign-In with Ethereum (SIWE)](https://eips.ethereum.org/EIPS/eip-4361): + +| SIWE Concept | Harbour Delegation Equivalent | +|--------------|-------------------------------| +| `domain` | `proof.domain` (signing service DID) | +| `address` | Holder DID (in VP) | +| `statement` | `metadata.description` (human-readable) | +| `uri` | Transaction reference (in transaction object) | +| `nonce` | `nonce` field | +| `issued-at` | `timestamp` field | +| `expiration-time` | `metadata.expiresAt` | +| `chain-id` | Implicit in transaction fields (e.g., `chain: "eip155:42793"`) | + +**Key differences**: + +1. **Wire format**: SIWE uses multiline plaintext; we use compact hash-based challenge +2. **Signature scheme**: SIWE uses EIP-191; we use VP proofs (Data Integrity / SD-JWT KB-JWT) +3. **Identity**: SIWE uses Ethereum address; we use DIDs +4. **Purpose**: SIWE is for authentication; ours is for transaction consent +5. **Data location**: SIWE puts all data in signed message; we put hash in signature, full data elsewhere + +The human-readable display format (§9) provides SIWE-like UX while the wire format remains compact for QR codes. + +--- + +## 14. Version History + +| Version | Date | Changes | +|---------|------|---------| +| 2.0.0 | 2026-02-24 | Major revision: hash-based challenge format, OID4VP alignment | +| 1.0.0 | 2026-02-24 | Initial specification (URL query string format) | diff --git a/docs/specs/did-method-evaluation.md b/docs/specs/did-method-evaluation.md new file mode 100644 index 0000000..b4cee07 --- /dev/null +++ b/docs/specs/did-method-evaluation.md @@ -0,0 +1,338 @@ +# DID Method Evaluation: did:web vs did:webs + +**Version**: 1.0.0 +**Date**: 2026-02-24 +**Status**: Decision Record + +--- + +## 1. Executive Summary + +This document evaluates `did:web` and `did:webs` DID methods for use in Harbour Credentials and the SimpulseID ecosystem. + +**Decision**: Use `did:web` for v1 with documented key rotation practices. Consider `did:webs` migration for v2 when tooling matures. + +--- + +## 2. Overview + +| Method | Description | +|--------|-------------| +| **did:web** | DID method that uses web domains for identifier resolution. DID documents are hosted as JSON files at well-known URLs. | +| **did:webs** | Extension of did:web that adds KERI (Key Event Receipt Infrastructure) for cryptographically verifiable key history and rotation. | + +--- + +## 3. Feature Comparison + +| Feature | did:web | did:webs | +|---------|---------|----------| +| **Web hosting** | ✅ Simple HTTPS | ✅ HTTPS + KERI AID | +| **Key rotation** | Manual update to did.json | ✅ Cryptographic key event log (KEL) | +| **Key history** | ❌ No verifiable history | ✅ Full verifiable history via KERI | +| **Revocation audit** | Trust web server | ✅ Cryptographically verifiable | +| **Offline verification** | ❌ Requires live fetch | ✅ Can verify with cached KEL | +| **Compromise recovery** | ❌ Difficult (trust server) | ✅ Pre-rotation keys | +| **Spec status** | W3C CCG Stable | ToIP Draft (active development) | +| **Complexity** | Low | High (KERI infrastructure) | + +--- + +## 4. Tooling Maturity (as of 2026-02) + +### 4.1 Libraries + +| Tool | did:web | did:webs | +|------|---------|----------| +| **Python** | Multiple (did-resolver, etc.) | `keri` v1.3.4 on PyPI ✅ | +| **TypeScript/JS** | did-resolver, veramo | Limited | +| **Universal Resolver** | ✅ Supported | ✅ Supported | + +### 4.2 Implementations + +| Implementation | Stars | Status | Notes | +|----------------|-------|--------|-------| +| **keripy** (WebOfTrust) | 74★ | Active (v2.0.0-dev5) | Core KERI Python library | +| **did-webs-resolver** (Hyperledger Labs) | 13★ | Active | Reference resolver | +| **Veridian Wallet** (Cardano Foundation) | 139★ | Active | KERI-native mobile wallet | + +### 4.3 Specification Status + +| Spec | Organization | Status | Last Update | +|------|--------------|--------|-------------| +| **did:web** | W3C CCG | Stable | 2023 | +| **did:webs** | Trust Over IP | Draft | Feb 2026 | +| **KERI** | WebOfTrust/IETF | Draft | Active | + +--- + +## 5. did:webs Advantages + +### 5.1 Cryptographic Key History + +With did:web, when a key is rotated, the old key is simply replaced. There's no cryptographic proof of what the previous key was or when it was rotated. + +With did:webs, every key event (rotation, revocation) is recorded in a Key Event Log (KEL) that is cryptographically chained: + +``` +Inception Event → Rotation Event 1 → Rotation Event 2 → ... +``` + +Each event is signed by the previous key, creating an unbroken chain of custody. + +### 5.2 Pre-rotation (Compromise Recovery) + +did:webs supports **pre-rotation**: when creating a key, you also commit to the hash of the next key. If your current key is compromised, the attacker cannot rotate to their own key because they don't know your pre-committed next key. + +### 5.3 Offline Verification + +With did:web, verifiers must fetch the current DID document from the web server each time. With did:webs, the KEL can be cached and verified offline—the cryptographic chain provides assurance even without network access. + +--- + +## 6. did:webs Concerns + +### 6.1 Specification Maturity + +- No formal 1.0 release from Trust Over IP +- Still evolving (breaking changes possible) +- Limited interoperability testing + +### 6.2 Operational Complexity + +did:webs requires KERI infrastructure: + +- **Witnesses**: Nodes that sign and store key events (for availability) +- **Watchers**: Nodes that monitor for duplicity (for security) +- **KERI Agent**: Software to manage key events + +This is significantly more complex than hosting a `did.json` file. + +### 6.3 Wallet Support + +| Wallet | did:web | did:webs | +|--------|---------|----------| +| Altme | ✅ | ❌ | +| Sphereon | ✅ | ❌ | +| walt.id | ✅ | ❌ | +| Veridian | ❌ | ✅ | + +Most VC wallets support did:web natively. did:webs support is limited to KERI-specific wallets. + +--- + +## 7. Current SimpulseID Implementation (did:web) + +Our current did:web implementation includes key rotation best practices: + +### 7.1 Key Rotation Model + +From `examples/did-web/README.md`: + +1. **Stable fragment IDs**: Key fragments (`#wallet-key-1`) never change +2. **Revocation timestamps**: Old keys marked with `"revoked": ""` +3. **Active key tracking**: Only non-revoked keys in `assertionMethod` + +```json +{ + "verificationMethod": [ + { + "id": "did:web:example.com:users:alice#wallet-key-1", + "type": "JsonWebKey", + "publicKeyJwk": { "kty": "EC", "crv": "P-256", ... }, + "revoked": "2026-01-15T00:00:00Z" + }, + { + "id": "did:web:example.com:users:alice#wallet-key-2", + "type": "JsonWebKey", + "publicKeyJwk": { "kty": "EC", "crv": "P-256", ... } + } + ], + "assertionMethod": [ + "did:web:example.com:users:alice#wallet-key-2" + ] +} +``` + +### 7.2 Trust Model + +- All DIDs controlled by `did:web:did.ascs.digital:services:trust-anchor` +- ASCS operates the web server (centralized trust anchor) +- Signatures are attestations, not control grants + +### 7.3 Limitations + +- Key history not cryptographically verifiable +- Must trust ASCS to honestly report revocations +- No protection against server compromise + +--- + +## 8. Wallet-Transparent did:webs Architecture + +A key architectural insight: **wallets don't need native KERI support** if Harbour operates the KERI infrastructure. + +### 8.1 The Insight + +KERI key events are just signed messages. Any wallet that can sign with ES256/P-256 can sign a KERI rotation event—it doesn't need to "understand" KERI semantics. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ HARBOUR │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ +│ │ KERI │ │ Witnesses │ │ did:webs Resolution │ │ +│ │ Agent │ │ (3+ nodes) │ │ & KEL Management │ │ +│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + ▲ + │ Signs rotation events, + │ VPs, etc. (just ES256) + │ +┌─────────────────────────────────────────────────────────────────┐ +│ ANY VC WALLET │ +│ │ +│ ┌──────────────────┐ Wallet only needs to: │ +│ │ P-256 Key │ ✓ Hold private key │ +│ │ (ES256) │ ✓ Sign payloads when asked │ +│ └──────────────────┘ ✗ No KERI awareness needed │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 8.2 Protocol Flow + +| Step | Wallet Action | Harbour Action | +|------|---------------|----------------| +| **DID Creation** | Generate P-256 keypair, share public key | Create KERI inception event, publish to witnesses | +| **Normal Use** | Sign VPs with P-256 key | Resolve did:webs, verify signatures against KEL | +| **Key Rotation** | Sign rotation payload with OLD key | Construct rotation event, coordinate witnesses, update KEL | +| **Verification** | (nothing) | Full KERI verification with key history | + +### 8.3 Rotation Protocol + +When a user needs to rotate their key: + +1. **User** generates new P-256 keypair in wallet +2. **Harbour** constructs KERI rotation event payload +3. **Harbour** sends payload to wallet for signing (standard ES256 signature request) +4. **Wallet** signs with OLD key (wallet doesn't know this is "KERI"—it's just a signature) +5. **Harbour** publishes signed rotation event to KERI witnesses +6. **Harbour** updates did:webs document + +The wallet's view: "Harbour asked me to sign something, I signed it." + +### 8.4 Implications + +| Concern | Resolution | +|---------|------------| +| Wallet support | ✅ Any ES256-capable wallet works | +| Complexity | Contained in Harbour infrastructure | +| User experience | No change from did:web | +| Cryptographic guarantees | Full KERI benefits (verifiable key history) | +| Operational burden | Harbour operates witnesses (can be distributed) | + +### 8.5 Considerations + +1. **Trust**: Users must trust Harbour to correctly manage their KERI events +2. **Availability**: Harbour witnesses must be highly available +3. **Signing UX**: Wallet must support signing arbitrary payloads (most do) +4. **Pre-rotation**: Still requires Harbour to manage pre-rotation commitments + +This architecture provides KERI's cryptographic benefits while maintaining compatibility with the existing wallet ecosystem. + +--- + +## 9. Migration Path to did:webs + +If/when did:webs matures, migration could follow this path: + +### Phase 1: Dual Resolution +- Maintain did:web documents as-is +- Add KERI AID to DID documents +- Resolve both methods, prefer did:webs when available + +### Phase 2: KERI Infrastructure +- Deploy KERI witnesses (minimum 3 recommended) +- Set up watchers for duplicity detection +- Migrate high-value DIDs (trust anchor, services) first + +### Phase 3: Full Migration +- Convert all user DIDs to did:webs +- Deprecate did:web-only resolution +- Update wallet integrations + +### Prerequisites for Migration (Updated) + +Based on the wallet-transparent architecture (§8), migration prerequisites are significantly reduced: + +- [ ] KERI witness infrastructure deployed (Harbour-operated, 3+ witnesses recommended) +- [ ] Rotation signing protocol implemented in Harbour +- [ ] did:webs resolver integrated (or use Universal Resolver) +- [x] ~~3+ major wallets support did:webs~~ **NOT REQUIRED** — Any ES256 wallet works +- [x] ~~did:webs spec reaches 1.0~~ **NOT BLOCKING** — Architecture is spec-compatible + +--- + +## 10. Recommendation + +### For v1 (Current) + +**Use did:web** with the following practices: + +1. ✅ P-256 keys (ES256 algorithm) +2. ✅ Stable fragment IDs for key references +3. ✅ Revocation timestamps (never delete keys) +4. ✅ Trust anchor pattern (centralized control with attestations) +5. ✅ Document key rotation procedures + +### For v2 (Future) + +**Implement wallet-transparent did:webs**: + +1. Deploy KERI infrastructure in Harbour (witnesses, watchers) +2. Implement rotation signing protocol (wallet signs KERI events as regular ES256 payloads) +3. Add did:webs resolution alongside did:web + +**Key insight**: We don't need to wait for wallet ecosystem support. Harbour can provide did:webs benefits to **any ES256-capable wallet** by operating the KERI infrastructure server-side. The wallet just signs—Harbour handles the KERI complexity. + +### Migration Prerequisites (Updated) + +- [ ] KERI witness infrastructure deployed (Harbour-operated) +- [ ] Rotation signing protocol implemented +- [ ] did:webs resolver integrated +- [ ] ~~3+ wallets support did:webs~~ (NOT required with transparent architecture) + +--- + +## 11. References + +### Specifications + +- [did:web Method Specification](https://w3c-ccg.github.io/did-method-web/) (W3C CCG) +- [did:webs Method Specification](https://trustoverip.github.io/tswg-did-method-webs-specification/) (Trust Over IP) +- [KERI Specification](https://weboftrust.github.io/ietf-keri/draft-ssmith-keri.html) (IETF Draft) +- [W3C DID Core](https://www.w3.org/TR/did-core/) (W3C Recommendation) + +### Implementations + +- [keripy](https://github.com/WebOfTrust/keripy) - Python KERI implementation +- [did-webs-resolver](https://github.com/hyperledger-labs/did-webs-resolver) - Hyperledger Labs +- [Veridian Wallet](https://github.com/cardano-foundation/veridian-wallet) - KERI-native wallet + +### Local Copies + +Reference specifications are stored in `docs/specs/references/` for offline access: + +- `did-web-method.txt` - did:web specification +- `did-webs-spec.md` - did:webs specification (concatenated) +- `keri-draft.md` - KERI IETF draft +- `oid4vp-1.0.txt` - OpenID4VP specification + +--- + +## 12. Version History + +| Version | Date | Changes | +|---------|------|---------| +| 1.1.0 | 2026-02-24 | Updated recommendation based on wallet-transparent KERI insight | +| 1.0.0 | 2026-02-24 | Initial evaluation and decision | diff --git a/docs/specs/references/README.md b/docs/specs/references/README.md new file mode 100644 index 0000000..967739b --- /dev/null +++ b/docs/specs/references/README.md @@ -0,0 +1,66 @@ +# Reference Specifications + +This directory contains downloaded copies of external specifications for offline reference and AI agent access. + +## ⚠️ Important Notice + +**These files are NOT original works of this project.** + +They are copies of specifications published by their respective standards organizations. The original terms, conditions, and licenses of each specification apply. + +## Files + +| File | Source | Organization | License | +|------|--------|--------------|---------| +| `oid4vp-1.0.txt` | [OpenID4VP 1.0](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html) | OpenID Foundation | [OpenID IPR](https://openid.net/intellectual-property/) | +| `did-web-method.txt` | [did:web Specification](https://w3c-ccg.github.io/did-method-web/) | W3C CCG | [W3C Document License](https://www.w3.org/copyright/document-license-2023/) | +| `did-webs-spec.md` | [did:webs Specification](https://github.com/trustoverip/tswg-did-method-webs-specification) | Trust Over IP Foundation | [ToIP License](https://github.com/trustoverip/tswg-did-method-webs-specification/blob/main/LICENSE.md) | +| `keri-draft.md` | [KERI Draft](https://github.com/WebOfTrust/ietf-keri) | WebOfTrust / IETF | Apache 2.0 | + +## Download Date + +All specifications were downloaded on **2026-02-24**. + +## Usage + +These files are provided for: + +1. **Offline reference** — Access specs without internet connectivity +2. **AI agent context** — Allow AI assistants to reference authoritative specifications +3. **Version pinning** — Ensure consistent spec versions during development + +## Updates + +To update these references: + +```bash +# OID4VP +curl -sL "https://openid.net/specs/openid-4-verifiable-presentations-1_0.html" | \ + python3 -c "..." > oid4vp-1.0.txt + +# did:web +curl -sL "https://w3c-ccg.github.io/did-method-web/" | \ + python3 -c "..." > did-web-method.txt + +# did:webs (from GitHub) +# See download script in repository + +# KERI +curl -sL "https://raw.githubusercontent.com/WebOfTrust/ietf-keri/main/draft-ssmith-keri.md" \ + -o keri-draft.md +``` + +## Authoritative Sources + +Always refer to the original sources for the most up-to-date and legally binding versions: + +- **OpenID4VP**: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html +- **did:web**: https://w3c-ccg.github.io/did-method-web/ +- **did:webs**: https://trustoverip.github.io/tswg-did-method-webs-specification/ +- **KERI**: https://weboftrust.github.io/ietf-keri/draft-ssmith-keri.html +- **W3C DID Core**: https://www.w3.org/TR/did-core/ +- **W3C VC Data Model**: https://www.w3.org/TR/vc-data-model-2.0/ + +## Disclaimer + +These copies are provided "as is" for convenience. The Harbour Credentials project makes no warranties about the accuracy or completeness of these copies. For authoritative interpretations, consult the original specifications and their issuing organizations. diff --git a/docs/specs/references/did-web-method.txt b/docs/specs/references/did-web-method.txt new file mode 100644 index 0000000..8bbb8c3 --- /dev/null +++ b/docs/specs/references/did-web-method.txt @@ -0,0 +1,429 @@ + + + did:web Method Specification + + DIDs that target a distributed ledger face significant practical + challenges in bootstrapping enough meaningful trusted data around + identities to incentivize mass adoption. + We propose a new DID method using a web domain's existing reputation. + + Introduction + + Preface + + The Web DID method specification conforms to the requirements specified + in the Decentralized Identifiers v1.0 Specification [[DID-CORE]]. For + more information about DIDs and DID method specifications, please also + see the [[?DID-PRIMER]] + + Examples + +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/jws-2020/v1" + ], + "id": "did:web:example.com", + "verificationMethod": [ + { + "id": "did:web:example.com#key-0", + "type": "JsonWebKey2020", + "controller": "did:web:example.com", + "publicKeyJwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "0-e2i2_Ua1S5HbTYnVB0lj2Z2ytXu2-tYmDFf8f5NjU" + } + }, + { + "id": "did:web:example.com#key-1", + "type": "JsonWebKey2020", + "controller": "did:web:example.com", + "publicKeyJwk": { + "kty": "OKP", + "crv": "X25519", + "x": "9GXjPGGvmRq9F6Ng5dQQ_s31mfhxrcNZxRGONrmH30k" + } + }, + { + "id": "did:web:example.com#key-2", + "type": "JsonWebKey2020", + "controller": "did:web:example.com", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "38M1FDts7Oea7urmseiugGW7tWc3mLpJh6rKe7xINZ8", + "y": "nDQW6XZ7b_u2Sy9slofYLlG03sOEoug3I0aAPQ0exs4" + } + }, + ], + "authentication": [ + "did:web:example.com#key-0", + "did:web:example.com#key-2" + ], + "assertionMethod": [ + "did:web:example.com#key-0", + "did:web:example.com#key-2" + ], + "keyAgreement": [ + "did:web:example.com#key-1", + "did:web:example.com#key-2" + ] +} + +{ + "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/secp256k1recovery-2020/v2"], + "id": "did:web:example.com", + "verificationMethod": [{ + "id": "did:web:example.com#address-0", + "type": "EcdsaSecp256k1RecoveryMethod2020", + "controller": "did:web:example.com", + "blockchainAccountId": "eip155:1:0x89a932207c485f85226d86f7cd486a89a24fcc12" + }], + "authentication": [ + "did:web:example.com#address-0" + ] +} + + Web DID Method Specification + + Target system + + The target system of the Web DID method is the host (or domain if the + host + is not specified) name when the domain specified by the DID is resolved + through the Domain Name System (DNS). + + Method name + + The namestring that shall identify this DID method is: web. + + A DID that uses this method MUST begin with the following prefix: + did:web. Per the DID specification, this string MUST be in + lowercase. The remainder of the DID, after the prefix, is specified + below. + + Method-specific identifier + + The method specific identifier is a fully qualified domain name that is + secured by a TLS/SSL certificate with an optional path to the DID + document. The formal rules describing valid domain name syntax are + described in [[RFC1035]], [[RFC1123]], and [[RFC2181]]. + + The method specific identifier MUST match the common name used in the + SSL/TLS certificate, and it MUST NOT include IP addresses. A port MAY be + included and the colon MUST be percent encoded to prevent a conflict + with paths. Directories and subdirectories MAY optionally be included, + delimited by colons rather than slashes. + +web-did = "did:web:" domain-name +web-did = "did:web:" domain-name * (":" path) + +did:web:w3c-ccg.github.io + +did:web:w3c-ccg.github.io:user:alice + +did:web:example.com%3A3000 + + Key Material and Document Handling + + Due to the way most web servers present content, it is likely that a + particular `did:web` document will be served with a media type of + `application/json`. If a document is retrieved and it is named + `did.json`, a few processing rules should apply: + + If an + `@context` is present at the root of the JSON document, + the document should be processed according to the JSON-LD rules. + If this is not possible, or if the document fails processing, the + document should be rejected from consideration as a `did:web` doc. + + If an + `@context` is present at the root of the JSON document, + and it passes JSON-LD processing, and it contains the context + `https://www.w3.org/ns/did/v1`, it may be further processed + as a + DID document as specified by section + 6.3.2 of + the + [[did-core]] specification. + + If no + `@context` is present, it should be processed via normal + JSON rules for DID processing as specified in section + 6.2.2 of the + [[did-core]] specification. + + Whenever a DID URL is present within a `did:web` document, it must + be an absolute URL. + + This includes URLs inside of embedded key material and other metadata, and + prevents + key confusion attacks. + + DID method operations + + There is intentionally no HTTP API specified for did:web method + operations leaving programmatic registrations and management to be + defined by each implementation, or based on their own requirements in + their web environment. + + Create (Register) + + Creating a DID is done by: + + applying at a domain name registrar for use of a domain name and + + storing the location of a hosting service, the IP address at a DNS + lookup service + + creating the DID document JSON-LD file including a suitable keypair, + e.g. using the Koblitz Curve, and storing the did.json + file under the well-known URL to represent the entire domain, or + under the specified path if many DIDs will be resolved in this + domain. + + For example, for the domain name `w3c-ccg.github.io`, the `did.json` + will be available under the following URL: + +did:web:w3c-ccg.github.io + -> https://w3c-ccg.github.io/.well-known/did.json + + If an optional path is specified rather the bare domain, the + did.json will be available under the specified path: + +did:web:w3c-ccg.github.io:user:alice + -> https://w3c-ccg.github.io/user/alice/did.json + + If an optional port is specified on the domain, the port colon + splitting the host and the port MUST be percent encoded to prevent + collision with the path. + +did:web:example.com%3A3000:user:alice + -> https://example.com:3000/user/alice/did.json + + Read (Resolve) + + The following steps MUST be executed to resolve the DID document from + a Web DID: + + Replace ":" with "/" in the method specific identifier to obtain the + fully qualified domain name and optional path. + + If the domain contains a port percent decode the colon. + + Generate an HTTPS URL to the expected location of the DID document + by prepending https://. + + If no path has been specified in the URL, append + /.well-known. + + Append /did.json to complete the URL. + + Perform an HTTP GET request to the URL using an agent + that can successfully negotiate a secure HTTPS connection, which + enforces the security requirements as described in . + + Verify that the ID of the resolved DID document matches the Web DID being resolved. + + When performing the DNS resolution during the HTTP GET + request, the client SHOULD utilize [[RFC8484]] in order to prevent + tracking of the identity being resolved. + + Update + + To update the DID document, the did.json has to be + updated. Please note that the DID will remain the same, but the + contents of the DID document could change, e.g., by including a new + verification key or adding service endpoints. + + Managing updates to the DID Document using a version control system + such as git and continious integration system such as GitHub Actions + can provide support for authentication and audit history. + + There is no HTTP API specified for the update process leaving + programmatic registrations and management to be defined by each + implementation. + + Deactivate (Revoke) + + To delete the DID document, the did.json has to be + removed or has to be no longer publicly available due to any other + means. + + Security and privacy considerations + + Authentication and Authorization + + This DID method does not specify any authentication or authorization + mechanism for writing to, removing or creating the DID Document, + leaving it up to implementations to protect did:web documents as with + any other web resource. + + It is up to implementer to secure their web environments according to + industry best practices for updating or otherwise managing web content + based on the specific needs of their threat environment. + + DNS Considerations + + DNS Security Considerations + + DNS presents many of the attack vectors that enable active security + and privacy attacks on the did:web method and it's important that + implementors address these concerns via proper configuration of DNS. + For example, without proper security of the DNS resolution via DNS over HTTPS it's + possible for active attackers to intercept the result of the DNS + resolution via a Man in the Middle attack which would point at a + malicious server with the incorrect DID Document. + + Additionally, implementors should be aware of issues presented by a + Spoofed DNS records where the record returned by a malicious DNS + Server is inauthentic and allows the record to be pointed at a + malicious server which contains a different DID Document. To prevent + this type of issue, usage of DNSSEC which is + RFC4033, + RFC4034, and + RFC4035. + + DNS Privacy Considerations + + Due to the nature of the did:web method relying upon a DNS in order to + resolve the web server, all resolutions of a did:web identifier have + the potential to be tracked by a DNS provider. Additionally, due to + the DID Document being stored on a web server, each time the DID + Document resource is retrieved, the web server has the ability to + track the resolution of the DID Document. To mitigate the issue of the + relying party being tracked when resolving the DID Document the + relying party should look to either use a trusted universal resolver + service to gain herd privacy, utilize a VPN service or perform a + resolution over the TOR network. Another emerging solution that will + be useful to address this is + draft-pauly-dprive-oblivious-doh-03 + + DID Document Integrity Verification + + Additional mechanisms such as Hashlinks + MAY be utilized to aid in integrity protection and verification of the + DID document. + + Under such a scenario the hash of the DID document could be recorded + to a trusted or distributed store and the retriever of the DID + document would generate a hash of the DID document in their posession + with the hash retrieved to ensure that no tampering with the DID + document had occurred. + + In-transit Security + + Guidance from + NIST SP 800-52 Rev. 2 + or superceding, MUST be followed for delivery of a `did:web` + document. + + It is additionally recommended to adhere to the latest recommendations + from OWASP's Transport Layer Protection Cheat Sheet [[OWASP-TRANSPORT]] + for hardening TLS configurations. + + Consult + NIST SP 800-57 + for guidance on cryptoperiod, which is the time span during which + a specific key is authorized for use or in which the keys for a given + system or application may remain in effect. + + TLS configuration MUST use at least SHA256, and SHOULD use SHA384, + POLY1305, or stronger, depending on the needs of your + operating environment. + + Delete action MAY be performed by domain name registrars or DNS lookup + services. + + As of this writing, TLS 1.2 or higher SHOULD be configured to use + only strong ciphers suites and to use sufficiently large key sizes. + As recommendations may be volatile these days, only the very latest + recommendations should be used. However, as a rule of thumb, + the following set of suites is a reasonable starting point: + + ECDHE with one of the strong curves {X25519, brainpoolP384r1, NIST + P-384, brainpoolP256r1, NIST P-256} shall be used as key exchange. + + AESGCM or ChaCha20 with 256 bit large keys shall be used for bulk + encryption + + ECDSA with one of the strong curves {brainpoolP384r1, NIST P-384, + brainpoolP256r1, NIST P-256} or RSA (at least 3072) shall be used. + + Authenticated Encryption with Associated Data (AEAD) shall be used + as Mac. + + Examples of strong SSL/TLS configurations for now are: + + ECDHE-ECDSA-AES256-GCM-SHA384, TLSv1.2, Kx=ECDH, Au=ECDSA, Enc=AESGCM(256), Mac=AEAD + + ECDHE-RSA-AES256-GCM-SHA384, TLSv1.2, Kx=ECDH, Au=RSA Enc=AESGCM(256), Mac=AEAD + + ECDHE-ECDSA-CHACHA20-POLY1305, TLSv1.2, Kx=ECDH, Au=ECDSA, Enc=ChaCha20-Poly1305, Mac=AEAD + + ECDHE-RSA-CHACHA20-POLY1305, TLSv1.2, Kx=ECDH, Au=RSA, Enc=ChaCha20-Poly1305, Mac=AEAD + + ECDHE-RSA-AES256-GCM-SHA384, TLSv1.2, Kx=ECDH, Au=RSA, Enc=AESGCM(256), Mac=AEAD + + ECDHE-ECDSA-AES256-GCM-SHA384, TLSv1.2, Kx=ECDH, Au=ECDSA, Enc=AESGCM(256), Mac=AEAD + + International Domain Names + + [[DID-CORE]] identifier syntax does not + allow Unicode in method name nor method specific identifiers. + + Implementers should be cautious when implementing + support for DID URLs that rely on domain names + or path components that contain Unicode characters. + + See also: + + UTS-46 + + IDNA 2008 + + Optional Path Considerations + + When optional paths to DID documents are used to resolve documents + rather than bare domains, verification with signed data proves that + the entity in control of the file indicated in the path has the + private keys. It does not prove that the domain operator has the + private keys. + + This example: + +did:web:example.com:u:bob + + resolves to the DID document at: + +https://example.com/u/bob/did.json + + In this scenario, it is probable that example.com has given user Bob + control over the DID in question, and proofs of control refer to Bob + rather than all of example.com. + + Cross-Origin Resource Sharing (CORS) Policy Considerations + + To support scenarios where DID resolution is performed by client + applications running in a web browser, the file served for the DID + document should be accessible by any origin. To enable this, + the DID document HTTP response can be set to include the + following header: + +Access-Control-Allow-Origin: * + + Reference implementations + + The code at uport-project/https-did-resolver + is intended to present a reference implementation of this DID method. Any + other implementations should ensure that they pass the test suite + described in /src/__tests__ before claiming compatibility. + + The code at transmute-industries/restricted-resolver + implements this specification. + + The code at reinkrul/java-did-resolvers + implements this specification as a Java library. + + diff --git a/docs/specs/references/did-webs-spec.md b/docs/specs/references/did-webs-spec.md new file mode 100644 index 0000000..68959e4 --- /dev/null +++ b/docs/specs/references/did-webs-spec.md @@ -0,0 +1,1809 @@ +# did:webs DID Method Specification + +Source: https://github.com/trustoverip/tswg-did-method-webs-specification +Downloaded: 2026-02-24 + +--- + +## abstract +## Abstract + +This document specifies a [DID +Method](https://www.w3.org/TR/did-1.0/#methods), +`did:webs`, that is web-based but innovatively secure. Like its +interoperable cousin, [`did:web`](https://w3c-ccg.github.io/did-method-web/), the +`did:webs` method uses traditional web infrastructure to publish DIDs and +make them discoverable. Unlike `did:web`, this method's trust is not rooted in +DNS, webmasters, X509, and certificate authorities. Instead, it uses [[ref: +KERI]] to provide a secure chain of cryptographic key events by those who +control the identifier including any of its delegators. + +The `did:webs` method does not need blockchains to establish trust. However, its use of +KERI allows for arbitrary blockchains to be referenced as an extra, optional +publication mechanism. This offers a potentital interoperability bridge from (or between) +blockchain ecosystems. Also, without directly supporting environments where the +web is not practical (e.g., IOT, Lo-Ra, Bluetooth, NFC), the method builds on a +foundation that can fully support those environments, making future interop of +identifiers between web and non-web a manageable step for users of `did:webs` identifiers. + +All DID methods make tradeoffs. The ones in `did:webs` result in a method that +is cheap, easy to implement, and scalable. No exotic or unproven cryptography is +required. Deployment is straightforward. Cryptographic trust is strongly +decentralized and governance is transparent. Signing authority is scalable through the +support of delegated identifiers. Regulatory challenges around the issue of +blockchains vanish. Any tech community or legal jurisdiction can use it. However, +`did:webs` _does_ depend on the web for publication and discovery. This may +color its decentralization and privacy. For its security, it adds [[ref: KERI]]. For users, the method also raises +the bar of accountability, thoughtfulness, and autonomy; this can be viewed as +either a drawback or a benefit (or both). + +--- + +## introduction +## Introduction +::: informative Introduction + +DID methods answer many questions. Two noteworthy ones are: + +* How is information about DIDs (in the form of DID documents) published and discovered? +* How is the trustworthiness of this information evaluated? + +The previously released `did:web` method merges these two questions, giving one answer: _Information is published and secured using familiar web mechanisms_. This has wonderful adoption benefits, because the processes and tooling are familiar to millions of developers. + +Unfortunately, this answer works better for the first question than the second. The current web is simply not very trustworthy. Websites get hacked. Sysadmins are sometimes malicious. DNS can be hijacked. X509 certs often prove less than clients wish. Browser validation checks are imperfect. Different certificate authorities have different quality standards. The processes that browser vendors use to pre-approve certificate authorities in browsers are opaque and centralized. TLS is susceptible to man-in-the-middle attacks on intranets with customized certificate chains. Governance is weak and inconsistent... + +Furthermore, familiar web mechanisms are almost always operated by corporate IT staff. This makes them an awkward fit for the ideal of decentralized autonomy — even if individuals can publish a DID on corporate web servers, those individuals are at the mercy of IT personnel for their security. + +The `did:webs` method described in this spec separates these two questions and answers them distinctively. _Information about DIDs_ is still published on the web, but its _trustworthiness_ derives from mechanisms entirely governed by individual DID controllers. This preserves most of the delightful convenience of `did:web`, while drastically upgrading security through authentic data that is end-verifiable. + +Within the context of `did:webs` the term *decentralized trust* includes verifiability, confidentiality, and privacy, but excludes veracity of the content. The latter is always a matter of (personal) evaluation of available reputational data and verifiable credentials (VCs). + +As a preview of syntax, see the below sample did:webs DID: + +``` +did:webs:example.com%3A3000:users:alice:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP +│ │ │ │ │ │ +│ │ │ │ │ └─ AID (KERI identifier) +│ │ │ │ └─────── Path component +│ │ │ └───────────── Path component +│ │ └───────────────────── Port (URL-encoded) +│ └──────────────────────────────── Host +└───────────────────────────────────────── Method +``` + +::: + + +--- + +## core +## Core Characteristics +This section is normative. + +### Method Name + +1. The method name that identifies this DID method SHALL be: `webs`. +1. A DID that uses this method MUST begin with the following prefix: `did:webs:`. +1. Per the DID specification, this string MUST be lower case. +1. The remainder of the DID, after the prefix, MUST be the case-sensitive [[ref: method-specific identifier]] +([[ref: MSI]]) described [below](#method-specific-identifier). + +::: informative Note on pronunciation +Note: when pronounced aloud, “webs” should become two syllables: the word “web” and the letter “s” (which stands for “secure”). Separating the final letter this way emphasizes that the method offers a security upgrade surpassing the one HTTPS gives to HTTP. +::: + +### Method-Specific Identifier + +1. The `did:webs` [[ref: method-specific identifier]] MUST have two parts, a [[ref: host]] with an optional path (identical to `did:web`), plus a KERI AID (autonomic identifier) that is always the final component of the path. +1. The ABNF definition of a `did:webs` DID MUST be as follows: + +```abnf +; did:webs DID structure +webs-did = "did:webs:" host [pct-encoded-colon port] *(":" path) ":" aid + +; 'host' as defined in RFC 1035, RFC 1123, and RFC 2181 +host = *( ALPHA / DIGIT / "-" / "." ) +; Simplified representation; actual RFCs have more complex rules for domains and IP addresses. +; In actual implementations replace with a mature host parsing library. + +; 'pct-encoded-colon' represents a percent-encoded colon +pct-encoded-colon = "%3A" / "%3a" ; Percent encoding for ':' + +; 'port' number (simplified version) +port = 1*5(DIGIT) + +; 'path' definition +path = 1*(ALPHA / DIGIT / "-" / "_" / "~" / "." / "/") + +aid = said + +; AID is a KERI SAID; SAID structure: +said = said-256 / said-512 + +; Base64URLSafe characters (RFC 4648, excluding padding) +base64urlsafe = ALPHA / DIGIT / "-" / "_" + +; The complete SAID primitive MUST conform to CESR code table [2], CESR spec Section 11.4. +; The following currently defined digest codes, for example, produce SAIDs of 44 or 88 characters total. + +; 256-bit SAIDs: 44 characters total (1 char code + 43 Base64URLSafe) +one-char-code = "E" / "F" / "G" / "H" / "I" +said-256 = one-char-code 43base64urlsafe + +; 512-bit SAIDs: 88 characters total (2 char code + 86 Base64URLSafe) +two-char-code = "0D" / "0E" / "0F" / "0G" +said-512 = two-char-code 86base64urlsafe +``` + +1. The [[ref: host]] MUST abide by the formal rules describing valid syntax found in [[ref: RFC1035]], [[ref: RFC1123]], and [[ref: RFC2181]]. +1. A port MAY be included and the colon MUST be percent encoded, like `%3a`, to prevent a conflict with paths. +1. Directories and subdirectories MAY optionally be included, delimited by colons rather than slashes. +1. The KERI AID is a unique identifier and MUST be derived from the [[ref: inception event]] of a KERI identifier. + +::: informative did:web compatibility +To be compatible with `did:web`, the AID is "just a path", the final (and perhaps only) path element. The presence of the required AID as a path element means that a `did:webs` always has a path,and so the "no path" version of a `did:web` that implicitly uses the `.well-known` location is not supported by `did:webs`. Any `did:webs` can be expressed as a `did:web` but the inverse is not true--a `did:webs` must include an AID. +::: + +### Target System(s) +1. As with `did:web`, `did:webs` MUST read data from whatever web server is referenced when the [[ref: host]] portion of one of its DID is resolved. +1. A `did:webs` DID MUST resolve to a [[ref: DID document]] using a simple text transformation to an HTTPS URL in the same way as a `did:web` DID. +1. A `did:web` DID and `did:webs` DID with the same [[ref: method-specific identifier]] SHOULD return the same DID document, except for minor differences in the `id`, `controller`, and `alsoKnownAs` top-level properties that pertain to the identifiers themselves. +1. As with `did:web`, the location of the `did:webs` [[ref: DID document]] MUST be determined by transforming the DID to an HTTPS URL as follows: + 1. MUST replace `did:webs` with `https://` + 1. MUST replace the "`:`"s in the method-specific identifier with path separators, "'/'"s + 1. MUST convert the optional port percent encoding ("`%3A`") to a colon if present. + 1. MUST append "`/did.json`" to the resulting string. +1. A GET on that URL MUST return the DID document. +1. The location of the [[ref: KERI event stream]] MUST be determined by transforming the previous URL as follows: + 1. MUST replace the trailing "`/did.json`" with "`/keri.cesr`". + 2. A GET on that URL MUST return the KERI event stream for the AID in the `did:webs` identifier. + 3. The KERI event stream MUST be [[ref: CESR]]-formatted (media type of application/cesr) and the KERI events must be verifiable using the KERI rules. +2. The `did:web` version of the DIDs MUST be the same (minus the `s`) and point to the same `did.json` file, but have no knowledge of the `keri.cesr` file. + +::: informative Target system and KERI verifiability +For more information, see the following sections in the implementors guide: +* [the set of KERI features needed](#the-set-of-keri-features-needed) to support `did:webs` + +A target system cannot forge or tamper with data protected by KERI, and if it deliberately serves an outdated copy, the duplicity is often detectable. Thus, any given target system in isolation can be viewed by this method as a dumb, untrusted server of content. It is the combination of target systems and some KERI mechanisms, _together_, that constitutes this method's verifiable data registry. In short, verifying the DID document by processing the [[ref: KERI event stream]] using KERI puts the "s" of "security" in `did:webs`. + +The following are some example `did:webs` DIDs and their corresponding DID documents and KERI event stream URLs, based on the examples from the [[ref: did:web Specification]], but with the (faked) AID +`12124313423525` added: +* `did:webs:w3c-ccg.github.io:12124313423525` + * The DID document URL would look like: `https://w3c-ccg.github.io/12124313423525/did.json` + * [[ref: KERI event stream]] URL would look like: `https://w3c-ccg.github.io/12124313423525/keri.cesr` +* `did:webs:w3c-ccg.github.io:user:alice:12124313423525` + * The DID document URL would look like: `https://w3c-ccg.github.io/user/alice/12124313423525/did.json` + * [[ref: KERI event stream]] URL would look like: `https://w3c-ccg.github.io/user/alice/12124313423525/keri.cesr` +* `did:webs:example.com%3A3000:user:alice:12124313423525` + * The DID document URL would look like: `https://example.com:3000/user/alice/12124313423525/did.json` + * [[ref: KERI event stream]] URL would look like: `https://example.com:3000/user/alice/12124313423525/keri.cesr` + +::: + +### AID controlled identifiers +1. [[ref: AID controlled identifiers]] MAY vary in how quickly they reflect the current identity information, DID document and [[ref: KERI event stream]]. Notably, as defined in section [Stable Identifiers On An Unstable Web](#stable-identifiers-on-an-unstable-web), the `id` property in the DID document will differ based on the web location of the DID document. +1. Different versions of the DID document and KERI event stream MAY reside in different locations depending on the replication capabilities of the controlling entity. +2. If the KERI event streams differ for `did:webs` DIDs with the same AID, the smaller KERI event stream MUST be a prefix of the larger KERI event stream (e.g., the only difference in the [[ref: KERI event streams]] being the extra events in one of the KERI event streams, not yet reflected in the other). +3. If the KERI event streams diverge from one other (e.g., one is not a subset of the other), both the KERI event streams and the DIDs MUST be considered invalid. +4. The verification of the KERI event stream SHOULD provide mechanisms for detecting the forking of the KERI event stream by using mechanisms such as KERI witnesses and watchers. + +::: informative AID and KERI event stream binding +Since an AID is a unique cryptographic identifier that is inseparably bound to the [[ref: KERI event stream]] it is associated with any AIDs and any `did:webs` DIDs that have the same AID component. It can be verifiably proven that they have the same controller(s). +::: + +### Handling Web Redirection + +1. A `did:webs` DID MAY be a "stable" (long-lasting) identifier that can be put into documents such as verifiable credentials, to be useful for a very long time -- generations. +1. When a `did:webs` DID is updated for another location the following rules MUST apply: + 1. Its AID MUST not change. + 1. The same [[ref: KERI event stream]] MUST be used to verify the DID document, with the only change being the [[ref: designated aliases]] list reflecting the new location identifier. + 1. If a resolver can find a newly named DID that uses the same AID, and the KERI event stream verifies the DID, then the resolver MAY consider the resolution to be successful and should note it in the resolution metadata. + +1. The following resolution paths that `did:webs` identfiers SHALL leverage to help in the face of resolution uncertainty includes: + 1. The `did:webs` DID SHALL provide other [[ref: designated aliases]] DID(s) that are anchored to the [[ref: KERI event stream]]. + 1. When a `did:webs` DID is permanently moved to some other location the resolver MAY redirect to any other `equivalentId` [[ref: designated aliases]]. + 1. The `id` in the DID document MUST be set to the new location. + 1. An `equivalentId` entry of the old location SHOULD remain for historical purposes and be anchored to the KERI event stream using [[ref: designated aliases]]. See section [Use of `equivalentId`](#use-of-equivalentid) for more details. + 1. If possible, the controller of the DID MAY use web redirects to allow resolution of the old location of the DID to the new location. + 1. If the previously published location of a `did:webs` DID is not redirected, an entity trying to resolve the DID MAY be able to find the data for the DID somewhere else using just the AID. + +::: informative Stable identifiers +The implementors guide contains more information about `did:webs` [[ref: stable identifiers on an unstable web]]. +::: + +### DID Method Operations + +#### Create + +1. Creating a `did:webs` DID MUST follow these rules: + 1. MUST choose the web URL where the DID document for the DID will be published, excluding the last element that will be the AID, once defined. + 1. MUST create a KERI AID and add it as the last element of the web URL for the DID. + 1. MUST add the appropriate KERI events to the AID's KERI logs that will correspond to properties of the DID document, such as verification methods and service endpoints. + 1. MUST derive the `did:webs` [[ref: DID document]] by processing the [[ref: KERI event stream]] according to section [DID Documents](#did-documents). + 1. For compatibility reasons, transformation of the derived `did:webs` DID document to the corresponding `did:web` DID document MUST be according to section [Transformation to did:web DID Document](#transformation-to-didweb-did-document). + 1. MUST make the did:web DID document resource (`did.json`) and the [[ref: KERI event stream]] resource (`keri.cesr`) available at the selected location. See section [Target System(s)](#target-systems) for further details about the locations of these resources. + +::: informative Publishing and hosting +Of course, the web server that serves the resources when asked might be a simple file server (as implied above) or an active component that generates them dynamically. Further, the publisher of the resources placed on the web can use capabilities like [CDNs] to distribute the resources. How the resources are posted at the required location is not defined by this spec; complying implementations need not support any HTTP methods other than GET. + +An active component might be used by the controller of the DID to automate the process of publishing and updating the DID document and [[ref: KERI event stream]] resources. +::: + +#### Read (Resolve) + +1. Resolving a `did:webs` DID MUST follow these steps: + 1. MUST convert the `did:webs` DID back to HTTPS URLs as described in section [Target System(s)](#target-systems). + 1. MUST execute HTTP GET requests on both the URL for the DID document (ending in `/did.json`) and the URL for the [[ref: KERI event stream]] (ending in `/keri.cesr`). + 1. MUST process the KERI event stream using [[ref: KERI Protocol]] Rules to verify it, then derive the `did:webs` [[ref: DID document]] by processing the KERI event stream according to section [DID Documents](#did-documents). + 1. MUST transform the retrieved `did:web` DID document to the corresponding `did:webs` DID document according to section [Transformation to did:webs DID Document](#transformation-to-didwebs-did-document). + 1. MUST verify that the derived `did:webs` DID document equals the transformed DID document. + 2. KERI-aware applications MAY use the KERI event stream to make use of additional capabilities enabled by the use of KERI. + +::: informative Scope of KERI capabilities +Capabilities beyond the verification of the DID document, the KERI event stream, and delegated identifiers are outside the scope of this specification. +::: + +#### Update + +1. If the AID of the `did:webs` DID is updatable, updates MUST be made to the AID by adding KERI events to +the [[ref: KERI event stream]]. +1. Updates to the KERI event stream that relate to the `did:webs` DID MUST be reflected in the DID Document as soon as possible. + 1. If the `did:webs` DID files are statically hosted then they MUST be republished to the web server, overwriting the existing files. + +#### Deactivate + +1. To deactivate a `did:webs` DID, A controller SHOULD execute a KERI event that has the effect of rotating the key(s) to null and continue to publish the DID document and KERI event stream. + 1. Once the deactivation events have been applied, the controller SHOULD regenerate the DID document from the [[ref: KERI event stream]] and republish both documents (`did.json` and `keri.cesr`) to the web server, overwriting the existing files. + 1. A controller SHOULD NOT make the DID document and [[ref: KERI event stream]] resources unavailable at the location where they have been published. + ::: informative Rationale for not removing DID files + This is considered to be a bad approach, as those resolving the DID will not be able to determine if the web service is offline or the DID has been deactivated. + ::: + +--- + +## keri +## KERI Fundamentals +::: informative KERI Fundamentals + +[[ref: Key Event Receipt Infrastructure)]] is a protocol for managing cryptographic keys, identifiers, and associated verifiable data structures. KERI was first described in an [academic paper](https://arxiv.org/abs/1907.02143), and its [specification](https://github.com/trustoverip/tswg-keri-specification) is currently incubated under [Trust Over IP Foundation](https://trustoverip.org/). The open source community that develops KERI-related technologies can be found at `https://github.com/WebOfTrust/keri`. This section outlines the fundamentals and components of the KERI protocol that are related to the `did:webs` method. + +### Autonomic Identifier (AID) + +An [[ref: autonomic identifier]] is a globally-unique persistent self-certifying identifier that serves as the primary root-of-trust of the KERI protocol. An AID is cryptographically bound to a [[ref: KEL]] that determines the evolution of its [[ref: key state]] using the [[ref: pre-rotation]] mechanism. AIDs and the underlying KERI protocol, by themselves, satisfy most of the [properties](https://www.w3.org/TR/did-core/#design-goals) that DIDs require, including decentralization, control, security, proof-based, and portability. For example, DIDs that have the same AID component are considered [equivalent](#equivalent-identifiers), allowing AIDs to be portable across different DID methods such as `did:webs` and `did:keri`. + +### Key Event Log (KEL) + +The binding between an [[ref: AID]] and its cryptographic keys is proved by a data structure called a [[ref: key event log]] that allows the [[ref: key states]] of the AID to evolve. For a `did:webs` DID, a KEL is an essential component in the [[ref: KERI event stream]] that is used to verify its authenticity. + +A KEL is a hash-chain append-only log and can be considered a variant of blockchain. However, a KEL differs from the traditional blockchain technology in at least two important ways: + +* It records the [[ref: key event]] history of a single AID with a single [[ref: controller]], instead of an arbitrarily large collection updated by other participants in the network. This makes a KEL memory-efficient, fast, cheap, and trivially scalable. +* It is fully [[ref: self-certifying]], meaning its correctness can be proved by direct inspection, without a distributed consensus algorithm or assumptions about trust in an external data source or its governance. + +These properties allows a KEL to be published anywhere, without special guarantees from its storage mechanism. For example, a KEL of an AID could be published and migrated between different KERI-compatible blockchain networks that use different DID methods. A KEL also records changes to key types and cryptographic algorithms, providing the AID portability and adaptability to multiple ecosystems throughout its lifecycle. + +### AID Derivation + +The value of an [[ref: AID]] is derived from the first [[ref: key event]], called the [[ref: inception event]], of a [[ref: KEL]]. The inception event includes inital public key(s), called the _current_ key(s), that can be used to control the AID. The cryptographic relationship between the AID and its keys eliminates an early chain-of-custody risk that plagues many other DID methods where an attacker uses compromised keys to create a DID without the DID controller's knowledge. This derivation process is similar to techniques used by `did:key`, `did:peer`, `did:sov`, and `did:v1`. + +The simplest AIDs, called non-transferrable [[ref: direct mode]] AIDs, have no additional input to the derivation function, and expose a degenerate KEL that can hold only the inception event. This KEL is entirely derivable from the AID itself, and thus requires no external data. Non-transferrable direct mode AIDs are ideal for ephemeral use cases and are excellent analogs to `did:key` and `did:peer` with `numalgo=0`. This is by no means not the only option as KERI offers richer choices that are especially valuable if an AID is intended to have a long lifespan. + +### Pre-rotation + +Public keys in the [Verification Methods](#verification-methods) of a `did:webs` DID can be changed via a mechanism called [[ref: pre-rotation]]. With pre-rotation, the inception event of the associated AID also includes the hash(s) of the _next_ key(s) that can be used to change the [[ref: key state]] of the AID. AIDs with one or more pre-rotated _next_ keys are called _transferrable_ AIDs because their control can be transferred to new keys. AIDs that do not use pre-rotation cannot change their keys and are thus _non-transferrable_. + +Pre-rotation has profound security benefits. If a malicious party steals the _current_ private key for a transferrable AID, they only accomplish _temporary_ mischief, because the already-existing KEL contains a commitment to a future state. This prevents them from rotating the stolen AID's key to an arbitrary value of their choosing. As soon as the AID controller suspects a compromise, they can change the key state of the AID using the pre-rotated _next_ key and locks the attacker out again. + +### Weighted Multisig + +The [[ref: KERI]] protocol supports weighted multi-signature scheme that allows for [conditional proof](#thresholds) in the [Verification Methods](#verification-methods). A multisig [[ref: AID]] distributes its control among multiple key holders. This includes simple M-of-N rules such as "3 of 5 keys must sign to change the AID's [[ref: key state]]". More sophisticated configurations are also supported: "Acme Corp's AID is controlled by the keys of 4 of 7 board members, or by the keys of the CEO and 2 board members, or by the keys of the CEO and the Chief Counsel." + +The security and recovery benefits of this feature are obvious when an AID references organizational identity. However, even individuals can benefit from this, when stakes are high. They simply store different keys on different devices, and then configure how their devices constitute a management quorum. This decreases risks from lost phones, for example. + +### Witnesses + +In the [[ref: direct mode]], the [[ref: controller]] of an [[ref: AID]] is responsible for the distribution of its [[ref: KEL]]. Since the controller may not be highly available, the controller may designate additional supporting infrastructure, called [[ref: witnesses]], for the [[ref: indirect mode]] distribution of the [[ref: KELs]]. Witnesses may or may not be under direct control of the AID's controller and could be deployed on either centralized or decentralized architectures, including blockchains. The witnesses are embedded in the AID's [[ref: key event]] and, if included in the [[ref: inception event]], alter the AID value. + +Unlike a blockchain with a distributed consensus mechanism, witnesses do not coordinate or come to consensus with one another during an update to its AID's [[ref: key event]] history. Hence, they need not be deeply trustworthy; merely by existing, they improve trust. This is because anyone changing an AID's key state or its set of witnesses, including the AID's legitimate controller, has to report those changes to the witnesses that are currently active, to produce a valid evolution of the KEL. The AID controller and all witnesses thus hold one another accountable. Further, it becomes possible to distinguish between duplicity and imperfect replication of key states. + +### Transaction Event Log (TEL) + +The [[ref: KERI]] protocol supports a verifiable data structure, called the [[ref: transaction event log]], that binds an [[ref: AID]] to non-repudiable data that is deterministically bound to the [[ref: key event]] history in the [[ref: KEL]]. Transactions that are recorded in a TEL may include things like the issuance and revocation of verifiable credentials or the fact that listeners on various service endpoints started or stopped. Like KELs, TELs are self-certifying and may also be published by KERI witnesses to enhance discoverability and provide watcher networks the ability to detect duplicity. For example, we demonstrate that in this spec in how we anchor [[ref: designated aliases]] as [verifiable data on a TEL](#verifiable-data-on-a-tel). + +### Web Independence + +Although _this DID method depends on web technology, KERI itself does not_. It's as easy to create AIDs on IOT devices as it is on the web. AIDs offer the same features regardless of their origin and besides HTTP, they are shareable over Lo-Ra, Bluetooth, NFC, Low Earth Orbit satellite protocols, service buses on medical devices, and so forth. Thus, KERI's AIDs offer a bridge between a web-centric DID method and lower-level IOT ecosystems. + +### Flexible Serialization + +[[ref: KELs]] and [[ref: TELs]] of `did:webs` DIDs (i.e., AIDs) are included in the [[ref: KERI event streams]] for verification of the DID documents. The KERI event streams use [[ref: Composable Event Streaming Representation ([[ref: CESR]])]] for data serialization. Although CESR is a deep subject all by itself, at a high level, it has two essential properties: + +* **Content in CESR is self-describing and supports serialization as binary and text**: That is in [[ref: CESR]], _a digital signature on a CESR data structure is stable no matter which underlying serialization format is used_. In effect it supports multiple popular serialization formats like JSON, CBOR, and MsgPack with room for many more. These formats can be freely mixed and combined in a CESR stream because of the self-describing nature of these individual CESR data structures. The practical effect is that developers get the best of both worlds: they can produce and consume data as text to display and debug in a human-friendly form and they can store and transmit this data in its tersest form, _all without changing the signature on the data structures_. +* **Cryptographic primitives are structured into compact standard representations**: Cryptographic primitives such as keys, hashes, digests, sealed-boxes, signatures, etc... are structured strings with a recognizable data type prefix and a standard representation in the stream. This means they are very terse and there is no need for the variety of representation methods that create interoperability challenges in other DID methods (`publicKeyJwk` versus `publicKeyMultibase` versus other; see the verification material section of the [[ref: DID specification]]. + +Despite this rich set of features, KERI imposes only light dependencies on developers. The cryptography it uses is familiar and battle-hardened, exactly what one would find in standard cryptography toolkits. For example, the python implementation (keripy) only depends on the `pysodium`, `blake3`, and `cryptography` python packages. Libraries for KERI exist in javascript, rust, and python. + +::: + + +--- + +## diddocuments +## DID Documents + +This section is normative. + +1. `did:webs` DID documents MUST be generated or derived from the [[ref: KERI event stream]] of the corresponding AID. + 1. Processing the KERI event stream of the AID, the generation algorithm MUST read the AID [[ref: KEL]] and any anchored [[ref: TELs]] to produce the DID document, including any designated alias ACDCs. +2. `did:webs` DID documents MUST be pure JSON. They MAY be processed as JSON-LD by prepending an `@context` if consumers of the documents wish. +3. All hashes, cryptographic keys, and signatures MUST be represented as [[ref: CESR]] strings. This is an approach similar to [multibase](https://github.com/multiformats/multibase), making them self-describing and terse. + +::: informative Understanding key state and KSN +To better understand the cryptographically verifiable data structures used, see the implementors guide description of the [KERI event stream chain of custody](#KERI-event-stream-chain-of-custody). To understand the KERI AID commands resulting in the [[ref: KERI Event Stream]] and the corresponding `did:webs` DID document see the original [[ref: didwebs Reference Implementation]] [getting started guide](https://github.com/GLEIF-IT/did-webs-resolver/blob/main/docs/getting_started.md). + +In KERI the calculated values that result from processing the [[ref: KERI event stream]] are referred to as the "current key state" and expressed +in the Key State Notice (KSN) record. An example of a KERI KSN record can be seen here: + +```json +{ + "v": "KERI10JSON000274_", + "i": "EeS834LMlGVEOGR8WU3rzZ9M6HUv_vtF32pSXQXKP7jg", + "s": "1", + "t": "ksn", + "p": "ESORkffLV3qHZljOcnijzhCyRT0aXM2XHGVoyd5ST-Iw", + "d": "EtgNGVxYd6W0LViISr7RSn6ul8Yn92uyj2kiWzt51mHc", + "f": "1", + "dt": "2021-11-04T12:55:14.480038+00:00", + "et": "ixn", + "kt": "1", + "k": ["DTH0PwWwsrcO_4zGe7bUR-LJX_ZGBTRsmP-ZeJ7fVg_4"], + "nt": 1, + "n": ["E6qpfz7HeczuU3dAd1O9gPPS6-h_dCxZGYhU8UaDY2pc"], + "bt": "3", + "b": [ + "BGKVzj4ve0VSd8z_AmvhLg4lqcC_9WYX90k03q-R_Ydo", + "BuyRFMideczFZoapylLIyCjSdhtqVb31wZkRKvPfNqkw", + "Bgoq68HCmYNUDgOz4Skvlu306o_NY-NrYuKAVhk3Zh9c" + ], + "c": [], + "ee": { + "s": "0", + "d": "ESORkffLV3qHZljOcnijzhCyRT0aXM2XHGVoyd5ST-Iw", + "br": [], + "ba": [] + }, + "di": "" +} +``` + +Using this key state as reference, we can identify the fields from the current key state that will translate to values +in the DID document. The following table lists the values from the example KSN and their associated values in a DID document: + +| Key State Field | Definition | DID Document Value | +|:---------------:|:---------------------------------------|:---------------------------------------------------------------------------------------------| +| `i` | The AID value | The DID Subject and DID Controller | +| `k` | The current set of public signing keys | Verification Methods with associated authentication and assertion verification relationships | +| `kt` | The current signing keys threshold | The threshold in a Verification Method of type `ConditionalProof2022` | + +In several cases above, the value from the key state is not enough by itself to populate the DID document. The following +sections detail the algorithm to follow for each case. + +::: + +### DID Subject + +This section is normative. + +1. The value of the `id` property in the DID document MUST be the `did:webs` DID that is being created or resolved. +1. The value from the `i` field of the key state notice MUST be the value after the last `:` in the [[ref: method-specific identifier]] ([[ref: MSI]]) of the `did:webs` DID, according to the syntax rules in section [Method-Specific Identifier](#method-specific-identifier). + +```json +{ + "id": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M" +} +``` + +### DID Controller + +This section is normative. + +1. The value of the `controller` property MUST be a single string that is the same as the `id` (the DID Subject). + +```json +{ + "controller": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M" +} +``` + +### Also Known As + +This section is normative. + +1. The `alsoKnownAs` property in the root of the DID document MAY contain any DID that has the same AID. + ::: informative designated aliases reference + See the [[ref: designated aliases]] section for information on how an AID anchors the `alsoKnownAs` identifiers to their [[ref: KERI event stream]]. + ::: + 1. As long as the identifier is resolvable, a designated aliases ACDC containing a given identifier MUST always be present in the `keri.cesr` stream in order for any identifier to be included in the `alsoKnownAs` section of a `did:webs` DID document. + ::: informative transformation rules note + Presence of designated alias ACDCs containing both `did:webs` and `did:web` identifiers are required to support the transformation rules between `did:webs` and `did:web` versions of a `did:webs` DID document while adhering to the security posture of KERI and ACDC. + + One potential way to implement this requirement is to ensure that resolving a given version of a `did:webs` DID document via the `versionId` parameter will return the DID document as of a given sequence number by analyzing the designated aliases ACDCs that were valid and unrevoked at that time. + ::: + +1. The `did:webs` version of the DID document MUST include the `did:web` version of the DID as an `alsoKnownAs` identifier, meaning it must also be in a valid, un-revoked designated aliases ACDC present in the `keri.cesr` stream. +1. The `did:web` version of the DID document MUST include the `did:webs` version of the DID as an `alsoKnownAs` identifier, meaning it must also be in a valid, un-revoked designated aliases ACDC present in the `keri.cesr` stream. +1. In order for the `did:webs` DID document to be valid, the `keri.cesr` stream MUST contain at least ONE designated aliases ACDC in which the DNS name and path for the `did:webs` identifier are committed to for both the `did:webs` and `did:web` versions of the identifier. + ::: informative + Committed to means placed in a designated aliases ACDC. + + This implies that the `did.json` for both the `did:webs` and `did:web` versions of a `did:webs` DID document will always contain a reciprocal link to one another that is also committed to by an event anchored into the KEL of the DID controller. + + A consumer of a DID document can only know that a given `did:web` DID is trustable and committed to by the controller of the AID supporting a `did:webs` DID only when that `did:web` DID is included in an un-revoked designated aliases ACDC. + + This protects against DID document malleability attacks where a malicious DID resolver host could inject fraudulent `did:web` DIDs into a DID document. As such, the consumer of a `did:webs` DID document should only trust `did:web` DIDs that are found in an un-revoked designated aliases ACDC present in the `keri.cesr` stream. + ::: +1. `did:webs` DIDs MUST provide the corresponding `did:keri` as an `alsoKnownAs` identifier. +1. The same AID MAY be associated with multiple `did:webs` DIDs, each with a different [[ref: host]] and/or path, but with the same AID. +1. `did:webs` DIDs MUST be listed in the Designated aliases attestation of the AID. +1. For each [[ref: AID controlled identifier]] DID defined above, an entry in the `alsoKnownAs` array in the DID document MUST be created. + +::: informative example alsoKnownAs +For the example DID `did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe` the following `alsoKnownAs` entries could be created: +```json +{ + "alsoKnownAs": [ + "did:web:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:web:example.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:web:foo.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:webs:foo.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" + ] +} +``` +::: + +### Verification Methods + +This section is normative. + +Each verification method for a `did:webs` DID is generated from signing keys located in the [[ref: KERI event stream]] of the controller of the `did:webs` DID. + +1. For each key listed in the array value of the `k` field of the KSN, a corresponding verification method MUST be generated in the DID document. +1. The `type` property in the verification method for each public key MUST be determined by the algorithm used to generate the public key. +1. The verification method types used MUST be registered in the [DID Specification Registries](https://www.w3.org/TR/did-extensions-properties/#verification-relationships) and added to this specification. +1. The `id` property of the verification method MUST be a relative DID URL and use the KERI key [[ref: CESR]] value as the value of the fragment component, e.g., `"id": "#"`. +1. The `controller` property of the verification method MUST be the value of the `id` property of the DID document. + + ::: informative controller and DID document id + DID Core requires each verification method to have a `controller` property whose value is a valid DID, but does not require that value to equal the `id` of the DID document (e.g., delegation may use a different controller). This specification requires that for `did:webs` the `controller` of every verification method equals the document `id`, since all verification material is derived from the same AID's key state. + ::: + +::: informative CESR and supported key types +KERI identifiers express public signing keys as Composable Event Streaming Representation (CESR) encoded strings in the `k` field of establishment events and the key state notice. CESR encoding encapsulates all the information needed to determine the cryptographic algorithm used to generate the key pair. + +At the time of this writing, KERI currently supports public key generation for Ed25519, Secp256k1 and Secp256r1 keys, and the protocol allows for others to be added at any time. + +For example, the key `DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr` in the DID document for the AID `ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe` becomes: + +```json + "verificationMethod": [ + {"id": "#DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr", + "type": "JsonWebKey", + "controller": "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "publicKeyJwk": { + "kid": "DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr", + "kty": "OKP", + "crv": "Ed25519", + "x": "evT4j6Yw3uHpwsw5NEmSR8-4x3S-BA-s6Thjd51oeOs" + } + } + ] +``` + +::: + +#### Ed25519 +1. Ed25519 public keys MUST be converted to a verification method with a type of `JsonWebKey` and `publicKeyJwk` property whose value is generated by decoding the [[ref: CESR]] representation of the public key out of the KEL and into its binary form (minus the leading 'B' or 'D' CESR codes) and generating the corresponding representation of the key in JSON Web Key form. + +For example, a KERI AID with only the following inception event in its KEL: +```json +{ + "v":"KERI10JSON00012b_", + "t":"icp", + "d":"ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "i":"ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "s":"0", + "kt":"1", + "k":["DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr"], + // ... +} +``` +would result in a DID document with the following verification methods array: +```json +"verificationMethod": [ + { + "id": "#DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr", + "type": "JsonWebKey", + "controller": "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "publicKeyJwk": { + "kid": "DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr", + "kty": "OKP", + "crv": "Ed25519", + "x": "evT4j6Yw3uHpwsw5NEmSR8-4x3S-BA-s6Thjd51oeOs" + } + } +] +``` + +#### Secp256k1 +1. Secp256k1 public keys MUST be converted to a verification method with a type of `JsonWebKey` and `publicKeyJwk` property whose value is generated by decoding the [[ref: CESR]] representation of the public key out of the KEL and into its binary form (minus the leading '1AAA' or '1AAB' CESR codes) and generating the corresponding representation of the key in JSON Web Key form. + +For example, a KERI AID with only the following inception event in its KEL: +```json +{ + "v": "KERI10JSON0001ad_", + "t": "icp", + "d": "EDP1vHcw_wc4M__Fj53-cJaBnZZASd-aMTaSyWEQ-PC2", + "i": "EDP1vHcw_wc4M__Fj53-cJaBnZZASd-aMTaSyWEQ-PC2", + "s": "0", + "kt": "1", + "k": [ + "1AAAAmbFVu-Wf8NCd63B9V0zsy7EgB_ocX2_n_Nh1FCmgF0Y", + ] + // ... +} +``` +would result in a DID document with the following verification methods array: + +```json + "verificationMethod": [ + { + "id": "#1AAAAmbFVu-Wf8NCd63B9V0zsy7EgB_ocX2_n_Nh1FCmgF0Y", + "type": "JsonWebKey", + "controller": "did:webs:example.com:EDP1vHcw_wc4M__Fj53-cJaBnZZASd-aMTaSyWEQ-PC2", + "publicKeyJwk": { + "kid": "1AAAAmbFVu-Wf8NCd63B9V0zsy7EgB_ocX2_n_Nh1FCmgF0Y", + "kty": "EC", + "crv": "secp256k1", + "x": "ZsVW75Z_w0J3rcH1XTOzLsSAH-hxfb-Q82HUUKaAXRg", + "y": "Lu6Uw785U3K05D-NPNoUInHPNUz9cGqWwjKjm5KL8FI" + } + } + ] +``` + +#### Thresholds +1. If the current signing keys threshold (the value of the `kt` field) is a string containing a number that is greater than 1, or if it is an array containing fractionally weighted thresholds, then in addition to the verification methods generated according to the rules in the previous sections, another verification method with a type of `ConditionalProof2022` MUST be generated in the DID document. This verification method type is defined [here](https://w3c-ccg.github.io/verifiable-conditions/). + 1. It MUST be constructed according to the following rules: + 1. The `id` property of the verification method MUST be a relative DID URL and use the AID as the value of the fragment component, e.g., `"id": "#"`. + 1. The `controller` property of the verification method MUST be the value of the `id` property of the DID document. + 1. If the value of the `kt` field is a string containing a number that is greater than 1 then the following rules MUST be applied: + 1. The `threshold` property of the verification method MUST be the integer value of the `kt` field in the current key state. + 1. The `conditionThreshold` property of the verification method MUST contain an array. For each key listed in the array value of the `k` field in the key state: + 1. The relative DID URL corresponding to the key MUST be added to the array value of the `conditionThreshold` property. + 1. If the value of the `kt` field is an array containing fractionally weighted thresholds then the following rules MUST be applied: + 1. The `threshold` property of the verification method MUST be the lowest common denominator (LCD) of all the fractions in the `kt` array. + 1. The `conditionWeightedThreshold` property of the verification method MUST contain an array. For each key listed in the array value of the `k` field in the key state, and for each corresponding fraction listed in the array value of the `kt` field: + 1. A JSON object MUST be added to the array value of the `conditionWeightedThreshold` property. + 1. The JSON object MUST contain a property `condition` whose value is the relative DID URL corresponding to the key. + 1. The JSON object MUST contain a property `weight` whose value is the numerator of the fraction after it has been expanded over the lowest common denominator (LCD) of all the fractions. + + For example, a KERI AID with only the following inception event in its KEL, and with a `kt` value greater than 1: + ```json + { + "v": "KERI10JSON0001b7_", + "t": "icp", + "d": "Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "i": "Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "s": "0", + "kt": "2", // Signing Threshold + "k": [ + "1AAAAg299p5IMvuw71HW_TlbzGq5cVOQ7bRbeDuhheF-DPYk", // Secp256k1 Key + "DA-vW9ynSkvOWv5e7idtikLANdS6pGO2IHJy7v0rypvE", // Ed25519 Key + "DLWJrsKIHrrn1Q1jy2oEi8Bmv6aEcwuyIqgngVf2nNwu" // Ed25519 Key + ], + } + ``` + results in a DID document with the following verification methods array: + ```json + { + "verificationMethod": [ + { + "id": "#Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "type": "ConditionalProof2022", + "controller": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "threshold": 2, + "conditionThreshold": [ + "#1AAAAg299p5IMvuw71HW_TlbzGq5cVOQ7bRbeDuhheF-DPYk", + "#DA-vW9ynSkvOWv5e7idtikLANdS6pGO2IHJy7v0rypvE", + "#DLWJrsKIHrrn1Q1jy2oEi8Bmv6aEcwuyIqgngVf2nNwu" + ] + }, + { + "id": "#1AAAAg299p5IMvuw71HW_TlbzGq5cVOQ7bRbeDuhheF-DPYk", + "type": "JsonWebKey", + "controller": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "publicKeyJwk": { + "kid": "1AAAAg299p5IMvuw71HW_TlbzGq5cVOQ7bRbeDuhheF-DPYk", + "kty": "EC", + "crv": "secp256k1", + "x": "NtngWpJUr-rlNNbs0u-Aa8e16OwSJu6UiFf0Rdo1oJ4", + "y": "qN1jKupJlFsPFc1UkWinqljv4YE0mq_Ickwnjgasvmo" + } + }, + { + "id": "#DA-vW9ynSkvOWv5e7idtikLANdS6pGO2IHJy7v0rypvE", + "type": "JsonWebKey", + "controller": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "publicKeyJwk": { + "kid": "DA-vW9ynSkvOWv5e7idtikLANdS6pGO2IHJy7v0rypvE", + "kty": "OKP", + "crv": "Ed25519", + "x": "A-vW9ynSkvOWv5e7idtikLANdS6pGO2IHJy7v0rypvE" + } + }, + { + "id": "#DLWJrsKIHrrn1Q1jy2oEi8Bmv6aEcwuyIqgngVf2nNwu", + "type": "JsonWebKey", + "controller": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "publicKeyJwk": { + "kid": "DLWJrsKIHrrn1Q1jy2oEi8Bmv6aEcwuyIqgngVf2nNwu", + "kty": "OKP", + "crv": "Ed25519", + "x": "LWJrsKIHrrn1Q1jy2oEi8Bmv6aEcwuyIqgngVf2nNws" + } + } + ] + } + ``` + + For example, a KERI AID with only the following inception event in its KEL, and a `kt` containing fractionally weighted thresholds: + ```json + { + "v": "KERI10JSON0001b7_", + "t": "icp", + "d": "Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "i": "Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "s": "0", + "kt": ["1/2", "1/3", "1/4"], // Signing Threshold + "k": [ + "1AAAAg299p5IMvuw71HW_TlbzGq5cVOQ7bRbeDuhheF-DPYk", // Secp256k1 Key + "DA-vW9ynSkvOWv5e7idtikLANdS6pGO2IHJy7v0rypvE", // Ed25519 Key + "DLWJrsKIHrrn1Q1jy2oEi8Bmv6aEcwuyIqgngVf2nNwu" // Ed25519 Key + ], + } + ``` + would result in a DID document with the following verification methods array: + + ```json + { + "verificationMethod": [ + { + "id": "#Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "type": "ConditionalProof2022", + "controller": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "threshold": 12, + "conditionWeightedThreshold": [ + { + "condition": "#1AAAAg299p5IMvuw71HW_TlbzGq5cVOQ7bRbeDuhheF-DPYk", + "weight": 6 + }, + { + "condition": "#DA-vW9ynSkvOWv5e7idtikLANdS6pGO2IHJy7v0rypvE", + "weight": 4 + }, + { + "condition": "#DLWJrsKIHrrn1Q1jy2oEi8Bmv6aEcwuyIqgngVf2nNwu", + "weight": 3 + } + ] + }, + { + "id": "#1AAAAg299p5IMvuw71HW_TlbzGq5cVOQ7bRbeDuhheF-DPYk", + "type": "EcdsaSecp256k1VerificationKey2019", + "controller": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "publicKeyJwk": { + "crv": "secp256k1", + "x": "NtngWpJUr-rlNNbs0u-Aa8e16OwSJu6UiFf0Rdo1oJ4", + "y": "qN1jKupJlFsPFc1UkWinqljv4YE0mq_Ickwnjgasvmo", + "kty": "EC", + "kid": "WjKgJV7VRw3hmgU6--4v15c0Aewbcvat1BsRFTIqa5Q" + } + }, + { + "id": "#DA-vW9ynSkvOWv5e7idtikLANdS6pGO2IHJy7v0rypvE", + "type": "Ed25519VerificationKey2020", + "controller": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "publicKeyMultibase": "zH3C2AVvLMv6gmMNam3uVAjZpfkcJCwDwnZn6z3wXmqPV" + }, + { + "id": "#DLWJrsKIHrrn1Q1jy2oEi8Bmv6aEcwuyIqgngVf2nNwu", + "type": "Ed25519VerificationKey2020", + "controller": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "publicKeyMultibase": "zDqYpw38nznAUJeeFdhKBQutRKpyDXeXxxi1HjYUQXLas" + } + ] + } + ``` + +### Verification Relationships + +This section is normative. + +`did:webs` commits to same keys for both authentication and assertion, a design facilitated by being built upon KERI. This section defines how the dual use of keys for `authentication` and `assertionMethod` is reflected normatively in verification relationships. A `did:webs` DID document MAY include any of these, or other properties, to express a specific verification relationship. Both the `authentication` and `assertionMethod` properties are optional though if included MUST follow the rules stated in this section. When verification relationships are present in a `did:webs` DID document, each committed signing key for a given `did:webs` DID MUST show up as both an `authentication` and `assertionMethod` verification relationship in the DID document. + +1. If the value of `kt` == 1 then the following rules MUST be applied: + 1. For each public key in `k` and its corresponding verification method, two verification relationships MUST be generated in the DID document. One verification relationship of type `authentication` and one verification relationship of type `assertionMethod`. + 1. The `authentication` verification relationship SHALL define that the DID controller can authenticate using each key. + 1. The `assertionMethod` verification relationship SHALL define that the DID controller can express claims using each key. +1. If the value of `kt` > 1 or if the value of `kt` is an array containing fractionally weighted thresholds then the following rules MUST be applied: + 1. For the verification method of type `ConditionalProof2022` (see section [Thresholds](#thresholds)), two verification relationships MUST be generated in the DID document. One verification relationship of type `authentication` and one verification relationship of type `assertionMethod`. + 1. The `authentication` verification relationship SHALL define that the DID controller can authenticate using a combination of multiple keys above the threshold. + 1. The `assertionMethod` verification relationship SHALL define that the DID controller can express claims using a combination of multiple keys above the threshold. +1. References to verification methods in the DID document MUST use the relative form of the identifier, e.g., `"authentication": ["#"]`. + +::: informative Use of private keys and key agreement +Private keys of a KERI AID can be used to sign a variety of data. This includes but is not limited to logging into a website, challenge-response exchanges, credential issuances, etc. + +For more information, see the [key agreement](#key-agreement) and [other key commitments](#other-key-commitments) section in the Implementors Guide. +::: + +### Service Endpoints + +This section is normative. + +1. `did:webs` DIDs MUST support service endpoints, including types declared in the DID Specification Registries, such as [DIDCommMessaging](https://www.w3.org/TR/did-extensions-properties/#didcommmessaging). + +::: informative Service endpoint mapping and metadata +For additional details about the mapping between KERI events and the Service Endpoints in the DID Document, see [Service Endpoint KERI events](#service-endpoint-event-details). + +It is important to note that DID document service endpoints are different than the KERI service endpoints detailed in [KERI Service Endpoints as DID Document Metadata](#keri-service-endpoints-as-did-document-metadata). +::: + +#### KERI Service Endpoints as DID Document Metadata +1. `did:webs` endpoints MUST be specified using the two data sets KERI uses to define service endpoints; Location Schemes and Endpoint Role Authorizations. + 1. Both MUST be expressed in KERI `rpy` events. + 1. For URL scheme endpoints that an AID has exposed, `did:webs` DIDs MUST use Location Schemes URLs. + 1. For endpoints that relate a role of one AID to another, `did:webs` DIDs MUST use KERI Endpoint Role Authorizations. + + For example, the following `rpy` method declares that the AID `EIDJUg2eR8YGZssffpuqQyiXcRVz2_Gw_fcAVWpUMie1` exposes the URL `http://localhost:3902` for scheme `http`: + ```json + { + // ... + "t": "rpy", + "r": "/loc/scheme", + "a": { + "eid": "EIDJUg2eR8YGZssffpuqQyiXcRVz2_Gw_fcAVWpUMie1", + "scheme": "http", + "url": "http://127.0.0.1:3901/" + } + } + ``` + For example, the AID listed in `cid` is the source of the authorization, the `role` is the role and the AID listed in the `eid` field is the target of the authorization. So in this example `EOGL1KGpOnRaZDIB11uZDCkhHs52_MtMXHd7EqUqwtA3` is being authorized as an Agent for `EIDJUg2eR8YGZssffpuqQyiXcRVz2_Gw_fcAVWpUMie1`. + ```json + { + // ... + "t": "rpy", + "r": "/end/role/add", + "a": { + "cid": "EIDJUg2eR8YGZssffpuqQyiXcRVz2_Gw_fcAVWpUMie1", + "role": "agent", + "eid": "EOGL1KGpOnRaZDIB11uZDCkhHs52_MtMXHd7EqUqwtA3" + } + } + ``` + +1. KERI service endpoints roles beyond `witness` SHOULD be defined using Location Scheme and Endpoint Authorization records in KERI. See the [KERI specification](https://trustoverip.github.io/kswg-keri-specification/#oobi-url-iurl) For more information about KERI roles. + +::: informative BADA-RUN and service endpoints +In KERI, service endpoints are defined by 2 sets of signed data using Best Available Data - Read, Update, Nullify ([[ref: BADA-RUN]]) rules for data processing. The protocol ensures that all data is signed in transport and at rest and versioned to ensure only the latest signed data is available. +::: + +### Transformation to `did:web` DID Document + +This section is normative. + +The DID document that exists as a resource on a webserver is compatible with the `did:web` DID method and therefore necessarily different from a `did:webs` DID document with regard to the `id`, `controller`, and `alsoKnownAs` properties. +1. To transform the `did:webs` form of the DID Document to a `did:web` the transformation MUST do the following: + 1. In the values of the top-level `id` and `controller` properties of the DID document, the transformation MUST replace the `did:webs` prefix string with `did:web`. + 1. In the value of the top-level `alsoKnownAs` property, the transformation MUST replace the entry that is now the new value of the `id` property (using `did:web`) with the old value of the `id` property (using `did:webs`). + 1. All other content of the DID document MUST not be modified. + + For example, this transformation is used during the [Create](#create) DID method operation, given the following `did:webs` DID document: + ```json + { + "id": "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "verificationMethod": [ + { + "id": "#DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr", + "type": "JsonWebKey", + "controller": "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "publicKeyJwk": { + "kid": "DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr", + "kty": "OKP", + "crv": "Ed25519", + "x": "evT4j6Yw3uHpwsw5NEmSR8-4x3S-BA-s6Thjd51oeOs" + } + } + ], + "service": [], + "alsoKnownAs": [ + "did:web:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:web:example.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:web:foo.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:webs:foo.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" + ] + } + ``` + the result of the transformation algorithm is the following `did:web` DID document: + ```json + { + "id": "did:web:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "verificationMethod": [ + { + "id": "#DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr", + "type": "JsonWebKey", + "controller": "did:web:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "publicKeyJwk": { + "kid": "DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr", + "kty": "OKP", + "crv": "Ed25519", + "x": "evT4j6Yw3uHpwsw5NEmSR8-4x3S-BA-s6Thjd51oeOs" + } + } + ], + "service": [], + "alsoKnownAs": [ + "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:web:example.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:web:foo.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:webs:foo.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" + ] + } + ``` + +### Transformation to `did:webs` DID Document + +This section is normative. + +This section defines an inverse transformation algorithm from a `did:web` DID document to a `did:webs` DID document. +1. Given a `did:web` DID document, a transformation to a `did:webs` DID document MUST have the following differences: + 1. In the values of the top-level `id` and `controller` properties of the DID document, the transformation MUST replace the `did:web` prefix string with `did:webs`. + 1. The value of the top-level `alsoKnownAs` property MUST replace the entry that is now the new value of the `id` property (using `did:webs`) with the old value of the `id` property (using `did:web`). + 1. All other content of the DID document MUST not be modificatied. +1. A `did:webs` resolver MUST use this transformation during the [Read (Resolve)](#read-resolve) DID method operation. + + For example, given the following `did:web` DID document: + ```json + { + "id": "did:web:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "verificationMethod": [ + { + "id": "#DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr", + "type": "JsonWebKey", + "controller": "did:web:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "publicKeyJwk": { + "kid": "DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr", + "kty": "OKP", + "crv": "Ed25519", + "x": "evT4j6Yw3uHpwsw5NEmSR8-4x3S-BA-s6Thjd51oeOs" + } + } + ], + "service": [], + "alsoKnownAs": [ + "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:web:example.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:web:foo.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:webs:foo.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" + ] + } + ``` + the result of the transformation algorithm is the following `did:webs` DID document: + ```json + { + "id": "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "verificationMethod": [ + { + "id": "#DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr", + "type": "JsonWebKey", + "controller": "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "publicKeyJwk": { + "kid": "DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr", + "kty": "OKP", + "crv": "Ed25519", + "x": "evT4j6Yw3uHpwsw5NEmSR8-4x3S-BA-s6Thjd51oeOs" + } + } + ], + "service": [], + "alsoKnownAs": [ + "did:web:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:web:example.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:web:foo.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:webs:foo.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" + ] + } + ``` + +### Full Example +::: informative Full Example + +To walk through a real-world example, please see the GETTING STARTED guide in the [[ref: didwebs Reference Implementation]] as it walks users through many did:webs related tasks (and associated KERI commands) to demonstrate how they work together. + +The following blocks contain fully annotated examples of a KERI AID with two events, an [[ref: inception event]] and an [[ref: interaction event]]. +* The Inception event designates some [[ref: witnesses]] in the `b` field. +* The Inception event designates multiple public signing keys in the `k` field. +* The Inception event designates multiple rotation keys in the `n` field. +* The Interaction event cryptographically anchors data associated with the SAID `EoLNCdag8PlHpsIwzbwe7uVNcPE1mTr-e1o9nCIDPWgM`. +* The reply `rpy` events specify an Agent endpoint, etc. + +Below, we show the KERI Event Stream that will be associated with the resulting generated DID document. These documents were generated for the `example.com` domain with no associated port or additional path defined: + +```json +{ + "v": "KERI10JSON0001b7_", + "t": "icp", + "d": "Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", // controller AID + "i": "Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "s": "0", + "kt": "2", // Signing Threshold + "k": [ + "1AAAAg299p5IMvuw71HW_TlbzGq5cVOQ7bRbeDuhheF-DPYk", // Secp256k1 Key + "DA-vW9ynSkvOWv5e7idtikLANdS6pGO2IHJy7v0rypvE", // Ed25519 Key + "DLWJrsKIHrrn1Q1jy2oEi8Bmv6aEcwuyIqgngVf2nNwu" // Ed25519 Key + ], + "nt": "2", + "n": [ + "Eao8tZQinzilol20Ot-PPlVz6ta8C4z-NpDOeVs63U8s", + "EAcNrjXFeGay9qqMj96FIiDdXqdWjX17QXzdJvq58Zco", + "EPoly9Tq4IPx41U-AGDShLDdtbFVzt7EqJUHmCrDxBdb" + ], + "bt": "3", + "b": [ + "BGKVzj4ve0VSd8z_AmvhLg4lqcC_9WYX90k03q-R_Ydo", + "BuyRFMideczFZoapylLIyCjSdhtqVb31wZkRKvPfNqkw", + "Bgoq68HCmYNUDgOz4Skvlu306o_NY-NrYuKAVhk3Zh9c" + ], + "c": [], + "a": [] + } +... +{ + "v": "KERI10JSON00013a_", + "t": "ixn", + "d": "Ek48ahzTIUA1ynJIiRd3H0WymilgqDbj8zZp4zzrad-w", + "i": "Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "s": "1", + "p": "Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "a": [ + { + "i": "EoLNCdag8PlHpsIwzbwe7uVNcPE1mTr-e1o9nCIDPWgM", + "s": "0", + "d": "EoLNCdag8PlHpsIwzbwe7uVNcPE1mTr-e1o9nCIDPWgM" + } + ] +} +... +{ + "v": "KERI10JSON000116_", + "t": "rpy", + "d": "EBiVyW6jPOeHX5briFYMQ4CefzqIZHgl-rrcXqj_t9ex", + "dt": "2022-01-20T12:57:59.823350+00:00", + "r": "/end/role/add", + "a": { + "cid": "Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "role": "agent", + "eid": "EIDJUg2eR8YGZssffpuqQyiXcRVz2_Gw_fcAVWpUMie1" + } +} +... +{ + "v": "KERI10JSON000116_", + "t": "rpy", + "d": "EBiVyW6jPOeHX5briFYMQ4CefzqIZHgl-rrcXqj_t9ex", + "dt": "2022-01-20T12:57:59.823350+00:00", + "r": "/end/role/add", + "a": { + "cid": "EIDJUg2eR8YGZssffpuqQyiXcRVz2_Gw_fcAVWpUMie1", + "role": "controller", + "eid": "EIDJUg2eR8YGZssffpuqQyiXcRVz2_Gw_fcAVWpUMie1" + } +} +... +{ + "v": "KERI10JSON0000fa_", + "t": "rpy", + "d": "EOGL1KGpOnRaZDIB11uZDCkhHs52_MtMXHd7EqUqwtA3", + "dt": "2022-01-20T12:57:59.823350+00:00", + "r": "/loc/scheme", + "a": { + "eid": "EIDJUg2eR8YGZssffpuqQyiXcRVz2_Gw_fcAVWpUMie1", + "scheme": "http", + "url": "http://foo.example.com:3901/" + } +} + + +... +{ + "v": "KERI10JSON000116_", + "t": "rpy", + "d": "EBiVyW6jPOeHX5briFYMQ4CefzqIZHgl-rrcXqj_t9ex", + "dt": "2022-01-20T12:57:59.823350+00:00", + "r": "/end/role/add", + "a": { + "cid": "EIDJUg2eR8YGZssffpuqQyiXcRVz2_Gw_fcAVWpUMie1", + "role": "controller", + "eid": "EIDJUg2eR8YGZssffpuqQyiXcRVz2_Gw_fcAVWpUMie1" + } +} +... +{ + "v": "KERI10JSON0000fa_", + "t": "rpy", + "d": "EOGL1KGpOnRaZDIB11uZDCkhHs52_MtMXHd7EqUqwtA3", + "dt": "2022-01-20T12:57:59.823350+00:00", + "r": "/loc/scheme", + "a": { + "eid": "EIDJUg2eR8YGZssffpuqQyiXcRVz2_Gw_fcAVWpUMie1", + "scheme": "http", + "url": "http://foo.example.com:3901/" + } +} + +``` + +Resulting DID document: +```json + "didDocument": { + "id": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "controller": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "alsoKnownAs": [ + "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "did:web:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "did:keri:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M" + ], + "verificationMethod": [ + { + "id": "#Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "type": "ConditionalProof2022", + "controller": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "threshold": 2, + "conditionThreshold": [ + "#1AAAAg299p5IMvuw71HW_TlbzGq5cVOQ7bRbeDuhheF-DPYk", + "#DA-vW9ynSkvOWv5e7idtikLANdS6pGO2IHJy7v0rypvE", + "#DLWJrsKIHrrn1Q1jy2oEi8Bmv6aEcwuyIqgngVf2nNwu" + ] + }, + { + "id": "#1AAAAg299p5IMvuw71HW_TlbzGq5cVOQ7bRbeDuhheF-DPYk", + "type": "JsonWebKey", + "controller": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "publicKeyJwk": { + "kid": "1AAAAg299p5IMvuw71HW_TlbzGq5cVOQ7bRbeDuhheF-DPYk", + "kty": "EC", + "crv": "secp256k1", + "x": "NtngWpJUr-rlNNbs0u-Aa8e16OwSJu6UiFf0Rdo1oJ4", + "y": "qN1jKupJlFsPFc1UkWinqljv4YE0mq_Ickwnjgasvmo" + } + }, + { + "id": "#DA-vW9ynSkvOWv5e7idtikLANdS6pGO2IHJy7v0rypvE", + "type": "JsonWebKey", + "controller": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "publicKeyJwk": { + "kid": "DA-vW9ynSkvOWv5e7idtikLANdS6pGO2IHJy7v0rypvE", + "kty": "OKP", + "crv": "Ed25519", + "x": "A-vW9ynSkvOWv5e7idtikLANdS6pGO2IHJy7v0rypvE" + } + }, + { + "id": "#DLWJrsKIHrrn1Q1jy2oEi8Bmv6aEcwuyIqgngVf2nNwu", + "type": "JsonWebKey", + "controller": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "publicKeyJwk": { + "kid": "DLWJrsKIHrrn1Q1jy2oEi8Bmv6aEcwuyIqgngVf2nNwu", + "kty": "OKP", + "crv": "Ed25519", + "x": "LWJrsKIHrrn1Q1jy2oEi8Bmv6aEcwuyIqgngVf2nNws" + } + } + ], + "authentication": [ + "#Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M" + ], + "assertionMethod": [ + "#Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M" + ], + "service": [ + { + "id": "#EIDJUg2eR8YGZssffpuqQyiXcRVz2_Gw_fcAVWpUMie1", + "type": "KeriAgent", + "serviceEndpoint": "http://foo.example.com:3901/" + } + ] + }... +``` + +::: + +### Basic KERI event details + +This section is normative. + +[DID Documents](#did-documents) introduced the core [[ref: KERI event stream]] and related DID Document concepts. This section provides additional details regarding the basic types of KERI events and how they relate to the DID document. + +#### Key state events +1. When processing the KERI event stream `did:webs` MUST account for two broad types of key state events (KERI parlance is 'establishment events') that can alter the key state of the AID. +1. Any change in key state of the AID MUST be reflected in the DID document. +1. If a key state event does not commit to a future set of rotation key hashes, then the AID SHALL NOT be rotated to new keys in the future (KERI parlance is that the key state of the AID becomes 'non-transferrable'). +1. If a key state event does commit to a future set of rotation key hashes, then any future key state rotation MUST be to those commitment keys. This foundation of [[ref: pre-rotation]] is post-quantum safe and allows the `did:webs` controller to recover from key compromise. +1. The [[ref: Inception event]] MUST be the first event in the [[ref: KEL]] that establishes the AID. + 1. This MUST define the initial key set + 1. If the controller(s) desire future key rotation (transfer) then the inception event MUST commit to a set of future rotation key hashes. + 1. When processing the [[ref: KERI event stream]], if there are no rotation events after the inception event, then this is the current key state of the AID and MUST be reflected in the DID Document as specified in [Verification Methods](#verification-methods) and [Verification Relationships](#verification-relationships). +1. [[ref: Rotation events]] MUST come after inception events. +1. If the controller(s) desires future key rotation (transfer) then the rotation event MUST commit to a set of future rotation key hashes. +1. Rotation events MUST only change the key state to the previously committed to rotation keys. +1. Either the inception event or the last rotation event, if any, is the current key state of the AID and MUST be reflected in the DID Document as specified in [Verification Methods](#verification-methods) and [Verification Relationships](#verification-relationships). + +::: informative KERI event references +You can learn more about the inception event in the [[ref: KERI specification]] and you can see an example inception event. +To learn about future rotation key commitment, see the sections about [pre-rotation](#pre-rotation) and the KERI specification. + +You can learn more about rotation events in the KERI specification and you can see an example rotation event. +To learn about future rotation key commitment, see the sections about [pre-rotation](#pre-rotation) and the [[ref: KERI specification]]. +::: + +### Delegation KERI event details +This section focuses on delegation relationships between KERI AIDs. [DID Documents](#did-documents) introduced the core [[ref: KERI event stream]] and related DID Document concepts. This section provides additional details regarding the types of KERI delegation events and how they relate to the DID document. See [Basic KERI event details](#basic-keri-event-details) for further detail on basic KERI event types including how they relate to the DID document. + +#### Delegation key state events +1. All delegation relationships MUST start with a delegated inception event. +1. Any change to the [[ref: Delegated inception event]] key state or delegated rotation event key state MUST be the result of a delegated rotation event. + +::: informative Delegation event summaries +Delegated [[ref: inception event]]: Establishes a delegated identifier. Either the delegator or the delegate can end the delegation commitment. + +Delegated [[ref: rotation event]]: Updates the delegated identifier commitment. Either the delegator or the delegate can end the delegation commitment. + +See the [[ref: KERI specification]] for an example of a delegated inception and rotation events. +::: + +Delegation service endpoints in the DID document are defined in the next section. + +### Service Endpoint Event Details + +This section is normative. + +In did:webs, KERI-derived service endpoints are defined by **Location Scheme** (`/loc/scheme`) reply (`rpy`) messages and, for roles other than witness, **Endpoint Role Authorization** (`/end/role/add`) `rpy` messages in the [[ref: KERI event stream]]. Location Scheme records declare URL(s) for a given scheme for an AID; Endpoint Role Authorization relates a role (e.g. mailbox, agent) of one AID to another. See [KERI Service Endpoints as DID Document Metadata](#keri-service-endpoints-as-did-document-metadata). + +When the event stream (or equivalent key state and endpoint data) for a `did:webs` DID establishes a witness, mailbox, or agent the DID document MUST include the associated service endpoint(s) in its `service` array. + +#### Witness Service Endpoint + +1. A witness service endpoint is produced when (1) the controller AID's [[ref: KEL]] designates the witness in its witness list (inception or latest rotation event `b` field), and (2) one or more Location Scheme `rpy` messages with `r` `/loc/scheme` declare URLs for that witness AID (`a.eid`) per scheme. The witness role is thus established by key state, not by an Endpoint Role Authorization `rpy`. +2. The DID document service entry SHALL use `type` `witness`, `id` relative to the DID of the form `#/witness`, and `serviceEndpoint` as an object whose keys are scheme names and values are the declared URLs. + +Location Scheme examples (witness AID declares https and tcp URLs): + +```json +{ + // ... + "t": "rpy", + "r": "/loc/scheme", + "a": { + "eid": "BJqHtDoLT_K_XyOgr2ejBOqD9276TYMTg2EEqWKs-V0q", + "scheme": "https", + "url": "https://wit1.testnet.gleif.org:5641/" + } +} +``` + +```json +{ + // ... + "t": "rpy", + "r": "/loc/scheme", + "a": { + "eid": "BJqHtDoLT_K_XyOgr2ejBOqD9276TYMTg2EEqWKs-V0q", + "scheme": "tcp", + "url": "tcp://wit1.testnet.gleif.org:5631/" + } +} +``` + +Resulting witness service entry: + +```json +{ + "id": "#BJqHtDoLT_K_XyOgr2ejBOqD9276TYMTg2EEqWKs-V0q/witness", + "type": "witness", + "serviceEndpoint": { + "https": "https://wit1.testnet.gleif.org:5641/", + "tcp": "tcp://wit1.testnet.gleif.org:5631/" + } +} +``` + +#### Mailbox Service Endpoint + +1. A mailbox service endpoint is produced when (1) an Endpoint Role Authorization `rpy` with `r` `/end/role/add` and `a.role` `mailbox` designates the mailbox AID (`a.eid`) for the controller AID (`a.cid`), and (2) one or more Location Scheme `rpy` messages with `r` `/loc/scheme` declare URLs for that mailbox AID. Implementations obtain mailbox endpoints from Endpoint Role data (e.g. KERI `ends` table keyed by controller and role) plus Location Scheme data (e.g. `locs` table). +2. The DID document service entry SHALL use `type` `mailbox`, `id` relative to the DID of the form `#/mailbox`, and `serviceEndpoint` as an object mapping scheme names to URLs (or a single URL when only one scheme applies). + +Endpoint Role Authorization example (controller designates mailbox): + +```json +{ + // ... + "t": "rpy", + "r": "/end/role/add", + "a": { + "cid": "Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "role": "mailbox", + "eid": "BJqHtDoLT_K_XyOgr2ejBOqD9276TYMTg2EEqWKs-V0q" + } +} +``` + +Location Scheme example (mailbox AID declares http URL): + +```json +{ + // ... + "t": "rpy", + "r": "/loc/scheme", + "a": { + "eid": "BJqHtDoLT_K_XyOgr2ejBOqD9276TYMTg2EEqWKs-V0q", + "scheme": "http", + "url": "http://mailbox.testnet.gleif.org:5635/" + } +} +``` + +Resulting mailbox service entry: + +```json +{ + "id": "#BJqHtDoLT_K_XyOgr2ejBOqD9276TYMTg2EEqWKs-V0q/mailbox", + "type": "mailbox", + "serviceEndpoint": { + "https": "https://mailbox.testnet.gleif.org:5635/", + } +} +``` + +#### Agent Service Endpoint + +1. An agent service endpoint is produced when (1) an Endpoint Role Authorization `rpy` with `r` `/end/role/add` and `a.role` `agent` designates the agent AID (`a.eid`) for the controller AID (`a.cid`), and (2) one or more Location Scheme `rpy` messages with `r` `/loc/scheme` declare URLs for that agent AID. Implementations obtain agent endpoints from Endpoint Role data (e.g. KERI `ends` table) plus Location Scheme data (e.g. `locs` table). +2. The DID document service entry SHALL use `type` `KeriAgent` (or `agent` where registered) and `serviceEndpoint` as an object mapping scheme names to URLs or a single URL, consistent with [KERI Service Endpoints as DID Document Metadata](#keri-service-endpoints-as-did-document-metadata). + +Endpoint Role Authorization example (controller designates agent): + +```json +{ + // ... + "t": "rpy", + "r": "/end/role/add", + "a": { + "cid": "Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "role": "agent", + "eid": "BJqHtDoLT_K_XyOgr2ejBOqD9276TYMTg2EEqWKs-V0q" + } +} +``` + +Location Scheme example (agent AID declares http URL): + +```json +{ + // ... + "t": "rpy", + "r": "/loc/scheme", + "a": { + "eid": "BJqHtDoLT_K_XyOgr2ejBOqD9276TYMTg2EEqWKs-V0q", + "scheme": "http", + "url": "http://agent.testnet.gleif.org:5636/" + } +} +``` + +Resulting agent service entry: + +```json +{ + "id": "#BJqHtDoLT_K_XyOgr2ejBOqD9276TYMTg2EEqWKs-V0q/agent", + "type": "agent", + "serviceEndpoint": { + "https": "https://agent.testnet.gleif.org:5636/", + } +} +``` + +#### Delegator Service Endpoint + +If the first event in the [[ref: KEL]] for a `did:webs` DID is a delegated inception event of type `dip` then it MUST include a delegator service endpoint in its DID document as follows. + +1. A delegated AID MUST include a service endpoint in its DID document that references its delegator. +1. When a delegator service endpoint is present, it MUST conform to the following requirements: + 1. The service `type` property MUST be set to `DelegatorOOBI`. + 1. The service `id` property MUST be the [[ref: SAID]] of the seal (anchor block) in the delegator's [[ref: KEL]] that commits to the delegate's [[ref: delegated inception event]]. + 1. The service `serviceEndpoint` property MUST be a valid [[ref: OOBI]] URL that resolves to the delegator's AID. +1. The delegator service endpoint enables verifiers to discover and validate the delegation relationship by retrieving the delegator's [[ref: KEL]]. + +For example, a `did:webs` DID that is a delegated AID MUST include, in its `service` array of the DID document, a delegator service endpoint similar to the following: + +```json +{ + "service": [{ + "id": "EDEvmKvGFjuip-J5dDw7sbVHxXA22s-pBO764CivsFt4", + "type": "DelegatorOOBI", + "serviceEndpoint": "http://keria:3902/oobi/ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" + }] +} +``` + +::: informative Delegator endpoint example explanation +In this example, the `id` field contains the [[ref: SAID]] of the seal in the delegator's [[ref: KEL]] that anchors the delegation commitment, and the `serviceEndpoint` provides the [[ref: OOBI]] URL to retrieve the delegator's key state so that the delegator's KEL may be searched for the delegation seal referred to by the `id` property. +::: + +### Designated Aliases +1. An AID controller SHALL specify the [[ref: designated aliases]] that will be listed in the `equivalentId` and `alsoKnownAs` properties by issuing a Designated aliases verifiable attestation as an ACDC. + 1. This attestation MUST contain a set of [[ref: AID controlled identifiers]] that the AID controller authorizes. + 1. If the identifier is a `did:webs` identifier then it is truly equivalent and MUST be listed in the `equivalentId` property. + 1. If the identifier is a DID then it MUST be listed in the `alsoKnownAs` property. + +#### Designated Aliases event details + +::: informative Designated aliases example +This is an example [[ref: designated aliases]] [[ref: ACDC]] attestation showing five designated aliases: +```json +{ + "v": "ACDC10JSON0005f2_", + "d": "EIGWggWL2IHiUzj1P2YuPA0-Uh55LTIu14KTvVQGrfvT", + "i": "ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "ri": "EAtQJEQMkkvlWxyfLbcLyv4kNeAI5Qsqe65vKIWnHKpx", + "s": "EN6Oh5XSD5_q2Hgu-aqpdfbVepdpYpFlgz6zvJL5b_r5", + "a": { + "d": "EJJjtYa6D4LWe_fqtm1p78wz-8jNAzNX6aPDkrQcz27Q", + "dt": "2023-11-13T17:41:37.710691+00:00", + "ids": [ + "did:web:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:web:example.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:web:foo.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:webs:foo.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" + ] + }, + "r": { + // rules section content... + } +} +``` +The resulting DID document based on the [[ref: designated aliases]] attestation above, contains: +* An `equivalentId` metadata for the did:webs:foo.com identifier +* Three `alsoKnownAs` identifiers: + * the did:webs:foo.com identifier is a Designated alias which is also in the equivalentId did document metadata. + * the did:web:example.com is a Designated alias + * NOTE: if the did:keri identifier were automatically generated and included from the AID then that would be a valid designated alias and alsoKnownAs value based on the AID +```json +{ + "didDocument": { + "id": "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "verificationMethod": [ + { + "id": "#DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr", + "type": "JsonWebKey", + "controller": "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "publicKeyJwk": { + "kid": "DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr", + "kty": "OKP", + "crv": "Ed25519", + "x": "evT4j6Yw3uHpwsw5NEmSR8-4x3S-BA-s6Thjd51oeOs" + } + } + ], + "service": [], + "alsoKnownAs": [ + "did:web:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:web:example.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:web:foo.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:webs:foo.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" + ] + }, + "didResolutionMetadata": { + "contentType": "application/did+json", + "retrieved": "2024-04-01T17:43:24Z" + }, + "didDocumentMetadata": { + "witnesses": [], + "versionId": "2", + "equivalentId": [ + "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:webs:foo.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" + ], + "didDocUrl": "http://did-webs-service:7676/ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe/did.json", + "keriCesrUrl": "http://did-webs-service:7676/ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe/keri.cesr" + } +} +``` + +::: + + +--- + +## did_metadata +## DID Metadata +This section is normative. + +This section describes the support of the `did:webs` method for metadata, including [[ref: DID resolution metadata]] and [[ref: DID document metadata]]. This metadata is returned by a DID Resolver in addition to the DID document. Also see the [DID Resolution](https://w3c.github.io/did-resolution/) specification for further details. + +### DID Resolution Metadata + +At the moment, this specification does not define the use of any specific [[ref: DID resolution metadata]] properties in the `did:webs` method, but may in the future include various metadata, such as which KERI Watchers were used during the resolution process. + +### DID Document Metadata + +This section of the specification defines how various DID document metadata properties are used by the `did:webs` method. + +#### Use of `versionId` + +The `versionId` DID document metadata property indicates the current version of the DID document that has been resolved. + +1. The `did:webs` versionId MUST be the sequence number (i.e. the `s` field) of the last event in the [[ref: KERI event stream]] that was used to construct the DID document according to the rules in section [DID Documents](#did-documents). +1. If the DID parameter `versionId` (see section [Support for `versionId`](#support-for-versionid)) was used when resolving the `did:webs` DID, and if the DID Resolution process was successful, then this corresponding DID document metadata property MUST be guaranteed to be equal to the value of the DID parameter. + +Example: + +```json +{ + "didDocument": { + "id": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M" + // ... other properties + }, + "didResolutionMetadata": { + }, + "didDocumentMetadata": { + "versionId": "2" + } +} +``` + +#### Use of `nextVersionId` + +The `nextVersionId` DID document metadata property indicates the next version of the DID document after the version that has been resolved. + +1. The `did:webs` `nextVersionId` MUST be the sequence number (i.e. the `s` field) of the next event in the [[ref: KERI event stream]] after the last one that was used to construct the DID document according to the rules in section [DID Documents](#did-documents). +1. This DID document metadata property MUST be present if the DID parameter `versionId` +(see section [Support for `versionId`](#support-for-versionid)) was used when resolving the `did:webs` DID, and if the value of that DID parameter was not the sequence number of the last event in the KERI event stream. + +Example: + +```json +{ + "didDocument": { + "id": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M" + // ... other properties + }, + "didResolutionMetadata": { + }, + "didDocumentMetadata": { + "versionId": "1", + "nextVersionId": "2" + } +} +``` + +#### Use of `equivalentId` + +The `equivalentId` DID document metadata property indicates other DIDs that refer to the same subject and are logically equivalent to the DID that has been resolved. It is similar to the `alsoKnownAs` DID document property (see section [Also Known As](#also-known-as)), but it has even stronger semantics, insofar as the logical equivalence is guaranteed by the DID method itself. + +1. The `did:webs` `equivalentId` metadata property SHOULD contain a list of the controller AID [[ref: designated aliases]] `did:webs` DIDs that differ in the [[ref: host]] and/or port portion of the [[ref: method-specific identifier]] but share the same AID. Also see section [[ref: AID controlled identifiers]]. +1. `equivalentId` depends on the controller AIDs array of [[ref: designated aliases]]. A `did:webs` identifier MUST not verify unless it is found in the `equivalentId` metadata that corresponds to the Designated aliases. + +> Note that [[ref: AID controlled identifiers]] like `did:web` and `did:keri` identifiers with the same AID are not listed in `equivalentId` because they do not have the same DID method. A `did:web` identifier with the same domain and AID does not have the same security characteristics as the `did:webs` identifier. Conversely, a `did:keri` identifier with the same AID has the same security characterisitcs but not the same dependence on the web. For these reasons, they are not listed in `equivalentId`. + +Example: + +```json +{ + "didDocument": { + "id": "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "verificationMethod": [ + { + "id": "#DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr", + "type": "JsonWebKey", + "controller": "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "publicKeyJwk": { + "kid": "DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr", + "kty": "OKP", + "crv": "Ed25519", + "x": "evT4j6Yw3uHpwsw5NEmSR8-4x3S-BA-s6Thjd51oeOs" + } + } + ], + "service": [], + "alsoKnownAs": [ + "did:web:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:web:example.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:web:foo.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:webs:foo.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" + ] + }, + "didResolutionMetadata": { + "contentType": "application/did+json", + "retrieved": "2024-04-01T17:43:24Z" + }, + "didDocumentMetadata": { + "witnesses": [], + "versionId": "2", + "equivalentId": [ + "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "did:webs:foo.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" + ], + "didDocUrl": "http://did-webs-service:7676/ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe/did.json", + "keriCesrUrl": "http://did-webs-service:7676/ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe/keri.cesr" + } +} +``` + +--- + +## didparameters +## DID Parameters +This section is normative. + +This section describes the support of the `did:webs` method for certain DID parameters. + +### Support for `versionId` + +The `did:webs` DID method supports the `versionId` DID parameter. This DID parameter is defined [here](https://www.w3.org/TR/did-core/#did-parameters). + +This allows clients to instruct a DID Resolver to return a specific version of a DID document, as opposed to the latest version. The `did:webs` DID method is ideally suited for this functionality, since a continuous, self-certifying stream of events lies at the heart of the DID method's design, see section [KERI Fundamentals](#keri-fundamentals). + +1. Valid values for this DID parameter MUST be the sequence numbers of events in the [[ref: KERI event stream]]. +1. When a `did:webs` DID is resolved with this DID parameter, a `did:webs` resolver MUST construct the DID document based on an AID's associated KERI events from the KERI event stream only up to (and including) the event with the sequence +number (i.e. the `s` field) that corresponds to the value of the `versionId` DID parameter. + +::: informative versionId example +See section [DID Documents](#did-documents) for details. + +Example: + +``` +did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M?versionId=1 +``` + +::: + +### Support for `transformKeys` + +The `did:webs` DID method supports the `transformKeys` DID parameter. This DID parameter is defined [here](https://github.com/decentralized-identity/did-spec-extensions/blob/main/parameters/transform-keys.md). + +1. This parameter MUST be implemented for a DID Resolver to return verification methods in a DID document in a desired format, such as `JsonWebKey` or `Ed25519VerificationKey2020`. + +::: informative transformKeys example +Example: + +``` +did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M?transformKeys=CesrKey +``` + +::: + +#### `CesrKey` and `publicKeyCesr` + +This specification defines the following extensions to the DID document data model in accordance with the [[ref: DID Spec Registries]]: + +1. Extension verification method `type` `CesrKey` MAY be available in a `did:webs` DID document to express a public key encoded in [[ref: CESR]] format. +1. Extension verification method property `publicKeyCesr` MAY be available in a `did:webs` DID document to provide a string value whose content is the CESR representation of a public key. +1. The verification method type `CesrKey` MAY be used as the value of the `transformKeys` DID parameter. + +::: informative CesrKey example +For example, a KERI AID with only the following inception event in its KEL: +```json +{ + "v": "KERI10JSON0001b7_", + "t": "icp", + "d": "Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "i": "Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "s": "0", + "kt": "1", + "k": [ + "1AAAAg299p5IMvuw71HW_TlbzGq5cVOQ7bRbeDuhheF-DPYk", // Secp256k1 Key + "DA-vW9ynSkvOWv5e7idtikLANdS6pGO2IHJy7v0rypvE", // Ed25519 Key + "DLWJrsKIHrrn1Q1jy2oEi8Bmv6aEcwuyIqgngVf2nNwu" // Ed25519 Key + ], + // ... +} +``` +and given the following the DID URL: +``` +did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M?transformKeys=CesrKey +``` +would result in a DID document with the following verification methods array: +```json +{ + "verificationMethod": [ + { + "id": "#1AAAAg299p5IMvuw71HW_TlbzGq5cVOQ7bRbeDuhheF-DPYk", + "type": "CesrKey", + "controller": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "publicKeyCesr": "1AAAAg299p5IMvuw71HW_TlbzGq5cVOQ7bRbeDuhheF-DPYk" + }, + { + "id": "#DA-vW9ynSkvOWv5e7idtikLANdS6pGO2IHJy7v0rypvE", + "type": "CesrKey", + "controller": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "publicKeyCesr": "DA-vW9ynSkvOWv5e7idtikLANdS6pGO2IHJy7v0rypvE" + }, + { + "id": "#DLWJrsKIHrrn1Q1jy2oEi8Bmv6aEcwuyIqgngVf2nNwu", + "type": "CesrKey", + "controller": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", + "publicKeyCesr": "DLWJrsKIHrrn1Q1jy2oEi8Bmv6aEcwuyIqgngVf2nNwu" + } + ] +} +``` + +::: + + +--- + +## security_considerations +## Security Considerations +This section is normative. + +There are many security considerations related to web requests, storing information securely, etc. It is useful to address these considerations along with the common security threats found on the web. + +### Common security threats + +1. All `did:webs` features MUST reduce the attack surface against common threats: + 1. Broken Object Level Authorization (BOLA) attacks MUST be eliminated or reduced. + 1. Denial of service (DoS) attacks MUST be eliminated or reduced. + 1. Deletion attacks MUST be eliminated or reduced. + 1. Duplicity detection MUST be available and reliable. + 1. Eclipse attacks MUST be eliminated or reduced. + 1. Forgery attacks MUST be eliminated or reduced. + 1. Impersonation attacks MUST be eliminated or reduced. + 1. Key Compromise attacks MUST be eliminated or reduced. + 1. Malleability attacks MUST be eliminated or reduced. + 1. Replay attacks MUST be eliminated or reduced. + +### Using HTTPS +Perfect protection from eavesdropping is not possible with HTTPS, for various +reasons. +1. URLs of DID documents and [[ref: KERI event streams]] SHOULD be hosted in a way that embodies accepted cybersecurity best practice. This is not strictly necessary to guarantee the authenticity of the data. However, the usage: + 1. MUST safeguard privacy + 1. MUST discourage denial of service + 1. MUST work in concert with defense-in-depth mindset + 1. MUST aid regulatory compliance + 1. MUST allow for high-confidence fetches of the DID document and a KERI event stream +1. A [[ref: host]] that uses a fully qualified domain name of the [[ref: method-specific identifier]] MUST be secured by a TLS/SSL certificate. + 1. The fully qualified domain name MUST match the common name used in the SSL/TLS certificate. + 1. The common name in the SSL/TLS certificate from the server MUST correspond to the way the server is referenced in the URL. This means that if the URL includes `www.example.com`, the common name in the SSL/TLS certificate must be `www.example.com` as well. +1. Unlike `did:web`, the URL MAY use an IP address instead. + 1. If it does, then the common name in the certificate MUST be the IP address as well. +1. Essentially, the URL and the certificate MUST NOT identify the server in contradictory ways; subject to that constraint, how the server is identified is flexible. + 1. The server certificate MAY be self-issued + 1. OR it MAY chain back to an unknown certificate authority. However, to ensure reasonable security hygiene, it MUST be valid. This has two meanings, both of which are required: +1. The certificate MUST satisfy whatever requirements are active in the client, such that the client does accept the certificate and use it to build and communicate over the encrypted HTTPS session where a DID document and KERI event stream are fetched. +1. The certificate MUST pass some common-sense validity tests, even if the client is very permissive: + 1. It MUST have a valid signature + 1. It MUST NOT be expired or revoked or deny-listed + 1. It MUST NOT have any broken links in its chain of trust. +1. If a URL of a DID document or KERI event streams results in a redirect, each URL MUST satisfy the same security requirements. +`www.example.com` as well. + +### International Domain Names + +1. As with `did:web`, implementers of `did:webs` SHOULD consider how non-ASCII characters manifest in URLs and DIDs. + 1. `did:webs` MUST follow the [[ref: DID-CORE]] identifier syntax which does not allow the direct representation of such characters in method name or method specific identifiers. This prevents a `did:webs` value from embodying a homograph attack. + 1. However, `did:webs` MAY hold data encoded with punycode or percent encoding. This means that IRIs constructed from DID values could contain non-ASCII characters that were not obvious in the DID, surprising a casual human reader. + 1. Caution is RECOMMENDED when treating a `did:webs` as the equivalent of an IRI. + 1. Treating it as the equivalent of a URL, instead, is RECOMMENDED as it preserves the punycode and percent encoding and is therefore safe. + +### Concepts for securing `did:webs` information + +The following security concepts are used to secure the data, files, signatures and other information in `did:webs`. +1. All security features and concepts in `did:webs` MUST use one or more of the following mechanisms: + 1. All data that requires the highest security MUST be [[ref: KEL]] backed. This includes any information that needs to be end-verifiably authentic over time: + 1. All [[ref: ACDCs]] used by a `did:webs` identifier MUST be one of the following: + 1. MAY be anchored to a KEL directly. + 1. MAY be anchored indirectly through a [[ref: TEL]] that itself is anchored to a KEL. + 1. All data that does not need to incur the cost of [[ref: KEL]] backing for secuirty but can benefit from the latest data-state such as a distributed data-base MUST use _Best Available Data - Read, Update, Nullify_ ([[ref: BADA-RUN]]). + 1. BADA-RUN information MUST be ordered in a consistent way, using the following: + 1. date-time MUST be used. + 1. key state MUST be used. + 1. Discovery information MAY use BADA-RUN because the worst-case attack on discovery information is a DDoS attack where nothing gets discovered. + 1. The controller(s) of the AID for a `did:webs` identifier MAY use BADA-RUN for service end-points as discovery mechanisms. + 2. All data that does not need the security of being KEL backed nor BADA-RUN SHOULD be served using _KERI Request Authentication Mechanism_ ([[ref: KRAM]]). + 1. For a `did:webs` resolver to be trusted it SHOULD use KRAM to access the service endpoints providing KERI event streams for verification of the DID document. + +#### Reducing the attack surface +::: informative Reducing the attack surface + +The above considerations have lead us to focus on KEL backed DID document blocks and data (designated alias ACDCs, signatures, etc) so that the trusted (local) did:webs resolver is secure. Any future features that could leverage BADA-RUN and [[ref: KRAM]] should be considered carefully according to the above considerations. + +See the implementors guide for more details about KEL backed, BADA-RUN, and KRAM: +* [[ref: On-Disk Storage]] +* [Alignment of Information to Security Posture](#alignment-of-information-to-security-posture) +* [Applying the concepts of KEL, BADA-RUN, and KRAM](#applying-the-concepts-of-kel) + +::: + + +--- + +## privacy_considerations +## Privacy Considerations +::: informative Privacy Considerations + +This section addresses the privacy considerations from [RFC6973](https://datatracker.ietf.org/doc/html/rfc6973) section 5. +For privacy considerations related to web infrastructure, see [`did:web` privacy considerations](https://w3c-ccg.github.io/did-method-web/#security-and-privacy-considerations). +Below we discuss privacy considerations related the KERI infrastructure. + +### Surveillance + +In KERI, a robust witness network along with consistent witness rotation provides protection from monitoring and association of +an individual's activity inside a KERI network. + +### Stored Data Compromise + +For resolvers that simply discover the Key State endorsed by another party in a discovery network, caching policies +of that network would guide stored data security considerations. In the event that a resolver is also the endorsing party, +meaning they have their own KERI identifier and are verifying the KEL and signing the Key State themselves, leveraging the +facilities provided by the KERI protocol (key rotation, witness maintenance, multi-sig) should be used to protect the identities +used to sign the Key State. + +### Unsolicited Traffic + +DID Documents are not required to provide endpoints and thus not subject to unsolicited traffic. + +### Misattribution + +This DID Method relies on KERI's duplicity detection to determine when the non-repudiable controller of a DID +has been inconsistent and can no longer be trusted. This establishment of non-repudiation enables consistent attribution. + +### Correlation + +The root of trust for KERI identifiers is entropy and therefore offers no direct means of correlation. In addition, KERI provides +two modes of communication, direct mode and indirect mode. Direct mode allows for pairwise (n-wise as well) relationships that +can be used to establish private relationships. + +See the KERI specification for [more information about direct and indirect modes](https://trustoverip.github.io/kswg-keri-specification/#introduction). + +### Identification + +The root of trust for KERI identifiers is entropy and therefore offers no direct means of identification. In addition, KERI provides +two modes of communication, direct mode and indirect mode. Direct mode allows for pairwise (n-wise as well) relationships that +can be used to establish private relationships. + +See the KERI specification for [more information about secure bindings and prefix derivation](https://trustoverip.github.io/kswg-keri-specification/#keris-secure-bindings) + +### Secondary Use + +The Key State made available in the metadata of this DID method is generally available and can be used by any party +to retrieve and verify the state of the KERL for the given identifier. + +### Disclosure + +No data beyond the Key State for the identifier is provided by this DID method. + +### Exclusion + +This DID method provides no opportunity for [correlation](#correlation), [identification](#identification) or +[disclosure](#disclosure) and therefore there is no opportunity to exclude the controller from knowing about data that others have +about them. + +::: + +--- + diff --git a/docs/specs/references/keri-draft.md b/docs/specs/references/keri-draft.md new file mode 100644 index 0000000..f62830b --- /dev/null +++ b/docs/specs/references/keri-draft.md @@ -0,0 +1,1601 @@ +--- + +title: "Key Event Receipt Infrastructure (KERI)" +abbrev: "KERI" +docname: draft-ssmith-keri-latest +category: info + +ipr: trust200902 +area: TODO +workgroup: TODO Working Group +keyword: Internet-Draft + +stand_alone: yes +smart_quotes: no +pi: [toc, sortrefs, symrefs] + +author: + - + name: S. Smith + organization: ProSapien LLC + email: sam@prosapien.com + +normative: + + KERI-ID: + target: https://github.com/WebOfTrust/ietf-keri + title: IETF KERI (Key Event Receipt Infrastructure) Internet Draft + date: 2022 + author: + ins: S. Smith + name: Samuel M. Smith + org: ProSapien LLC + + CESR-ID: + target: https://github.com/WebOfTrust/ietf-cesr + title: IETF CESR (Composable Event Streaming Representation) Internet Draft + date: 2022 + author: + ins: S. Smith + name: Samuel M. Smith + org: ProSapien LLC + + SAID-ID: + target: https://github.com/WebOfTrust/ietf-said + title: IETF SAID (Self-Addressing IDentifier) Internet Draft + date: 2022 + author: + ins: S. Smith + name: Samuel M. Smith + org: ProSapien LLC + date: 2022 + + OOBI-ID: + target: https://github.com/WebOfTrust/ietf-oobi + title: IETF OOBI (Out-Of-Band-Introduction) Internet Draft + author: + ins: S. Smith + name: Samuel M. Smith + org: ProSapien LLC + date: 2022 + + DIDK-ID: + target: https://github.com/WebOfTrust/ietf-did-keri + title: IETF DID-KERI Internet Draft + author: + ins: P. Feairheller + name: Phil Feairheller + org: GLEIF + + RFC8259: JSON + + JSOND: + target: https://www.json.org/json-en.html + title: JavaScript Object Notation Delimiters + + RFC8949: CBOR + + CBORC: + target: https://en.wikipedia.org/wiki/CBOR + title: CBOR Mapping Object Codes + + MGPK: + target: https://github.com/msgpack/msgpack/blob/master/spec.md + title: Msgpack Mapping Object Codes + + +informative: + + KERI: + target: https://arxiv.org/abs/1907.02143 + title: Key Event Receipt Infrastructure (KERI) + date: 2021 + author: + ins: S. Smith + name: Samuel M. Smith + org: ProSapien LLC + + UIT: + target: https://github.com/SmithSamuelM/Papers/blob/master/whitepapers/IdentifierTheory_web.pdf + title: Universay Identifier Theory + seriesinfo: WhitePaper + date: 2020 + author: + ins: S. Smith + name: Samuel M. Smith + + DAD: + target: https://github.com/SmithSamuelM/Papers/blob/master/whitepapers/DecentralizedAutonomicData.pdf + title: "Decentralized Autonomic Data (DAD) and the three R's of Key Management" + seriesinfo: WhitePaper + date: 2018 + author: + ins: S. Smith + name: Samuel M. Smith + + IDSys: + target: https://github.com/SmithSamuelM/Papers/blob/master/whitepapers/Identity-System-Essentials.pdf + title: Identity System Essentials + seriesinfo: WhitePaper + date: 2016 + author: + - + ins: S. Smith + name: Samuel M. Smith + - + ins: D. Khovratovich + name: Dmitry Khovratovich + + RFC4648: Base64 + + RFC0020: ASCII + + RFC3986: URI + + RFC8820: URIDesign + + RFC4627: JSONMIME + + JSch: + target: https://json-schema.org + title: JSON Schema + + JSch_202012: + target: https://json-schema.org/draft/2020-12/release-notes.html + title: "JSON Schema 2020-12" + + RFC6901: JSONPTR + + HCR: + target: https://en.wikipedia.org/wiki/Collision_resistance + title: Hash Collision Resistance + + ITPS: + target: https://en.wikipedia.org/wiki/Information-theoretic_security + title: Information-Theoretic and Perfect Security + + OTP: + target: https://en.wikipedia.org/wiki/One-time_pad + title: One-Time-Pad + + VCphr: + target: https://www.ciphermachinesandcryptology.com/en/onetimepad.htm + title: Vernom Cipher (OTP) + + SSplt: + target: https://www.ciphermachinesandcryptology.com/en/secretsplitting.htm + title: Secret Splitting + + SShr: + target: https://en.wikipedia.org/wiki/Secret_sharing + title: Secret Sharing + + CSPRNG: + target: https://en.wikipedia.org/wiki/Cryptographically-secure_pseudorandom_number_generator + title: Cryptographically-secure pseudorandom number generator (CSPRNG) + + IThry: + target: https://en.wikipedia.org/wiki/Information_theory + title: Information Theory + + BLAKE3: + target: ttps://github.com/BLAKE3-team/BLAKE3 + title: BLAKE3 + + BLAKE3Spec: + target: https://github.com/BLAKE3-team/BLAKE3-specs/blob/master/blake3.pdf + title: BLAKE3 one function, fast everywhere + + BLAKE3Hash: + target: https://www.infoq.com/news/2020/01/blake3-fast-crypto-hash/ + title: “BLAKE3 Is an Extremely Fast, Parallel Cryptographic Hash” + seriesinfo: InfoQ + date: 2020-01-12 + + QCHC: + target: https://cr.yp.to/hash/collisioncost-20090823.pdf + title: "Cost analysis of hash collisions: Will quantum computers make SHARCS obsolete?" + + TMCrypto: + target: https://eprint.iacr.org/2019/1492.pdf + title: “Too Much Crypto” + date: 2021-05-24 + author: + ins: J. Aumasson + name: Jean-Philippe Aumasson + + EdSC: + target: https://eprint.iacr.org/2020/823 + title: "The Provable Security of Ed25519: Theory and Practice Report" + + PSEd: + target: https://ieeexplore.ieee.org/document/9519456?denied= + title: "The Provable Security of Ed25519: Theory and Practice" + seriesinfo: "2021 IEEE Symposium on Security and Privacy (SP)" + date: 2021-05-24 + author: + - + ins: J. Brendel + name: Jacqueline Brendel + - + ins: C. Cremers + name: Cas Cremers + - + ins: D. Jackson + name: Dennis Jackson + - + ins: M. Zhao + name: Mang Zhao + + TMEd: + target: https://eprint.iacr.org/2020/1244.pdf + title: Taming the many EdDSAs + + Salt: + target: https://medium.com/@fridakahsas/salt-nonces-and-ivs-whats-the-difference-d7a44724a447 + title: Salts, Nonces, and Initial Values + + Stretch: + target: https://en.wikipedia.org/wiki/Key_stretching + title: Key stretching + + HDKC: + target: https://github.com/WebOfTrustInfo/rwot1-sf/blob/master/topics-and-advance-readings/hierarchical-deterministic-keys--bip32-and-beyond.md + title: "Hierarchical Deterministic Keys: BIP32 & Beyond" + author: + - + ins: C. Allen + name: Christopher Allen + - + ins: S. Applecline + name: Shannon Applecline + + OWF: + target: https://en.wikipedia.org/wiki/One-way_function + title: One-way_function + + COWF: + target: http://www.crypto-it.net/eng/theory/one-way-function.html + title: One-way Function + seriesinfo: Crypto-IT + + RB: + target: https://en.wikipedia.org/wiki/Rainbow_table + title: Rainbow Table + + DRB: + target: https://www.commonlounge.com/discussion/2ee3f431a19e4deabe4aa30b43710aa7 + title: Dictionary Attacks, Rainbow Table Attacks and how Password Salting defends against them + + BDay: + target: https://en.wikipedia.org/wiki/Birthday_attack + title: Birthday Attack + + BDC: + target: https://auth0.com/blog/birthday-attacks-collisions-and-password-strength/ + title: Birthday Attacks, Collisions, And Password Strength + + DHKE: + target: https://www.infoworld.com/article/3647751/understand-diffie-hellman-key-exchange.html + title: "Diffie-Hellman Key Exchange" + + KeyEx: + target: https://libsodium.gitbook.io/doc/key_exchange + title: Key Exchange + + Hash: + target: https://en.wikipedia.org/wiki/Cryptographic_hash_function + title: Cryptographic Hash Function + + W3C_DID: + target: https://w3c-ccg.github.io/did-spec/ + title: "W3C Decentralized Identifiers (DIDs) v1.0" + + PKI: + target: https://en.wikipedia.org/wiki/Public-key_cryptography + title: Public-key Cryptography + + SCPK: + target: https://link.springer.com/content/pdf/10.1007%2F3-540-46416-6_42.pdf + title: Self-certified public keys + seriesinfo: "EUROCRYPT 1991: Advances in Cryptology, pp. 490-497, 1991" + author: + ins: M. Girault + name: Marc Girault + + SCURL: + target: https://pdos.csail.mit.edu/~kaminsky/sfs-http.ps + title: "SFS-HTTP: Securing the Web with Self-Certifying URLs" + seriesinfo: "Whitepaper, MIT, 1999" + author: + - + ins: M. Kaminsky + name: M. Kaminsky + - + ins: E. Banks + name: E. Banks + + SFS: + target: https://pdos.csail.mit.edu/~kaminsky/sfs-http.ps + title: "Self-certifying File System" + seriesinfo: “MIT Ph.D. Dissertation" + date: 2000-06-01 + author: + ins: D. Mazieres + name: David Mazieres + + SCPN: + target: https://dl.acm.org/doi/pdf/10.1145/319195.319213 + title: "Escaping the Evils of Centralized Control with self-certifying pathnames" + seriesinfo: “MIT Laboratory for Computer Science, 2000" + author: + - + ins: D. Mazieres + name: David Mazieres + - + ins: M. Kaashoek + name: M. F. Kaashoek + + RFC0791: IP + + RFC0799: IND + + DNS: + target: https://en.wikipedia.org/wiki/Domain_Name_System + title: Domain Name System + + CAA: + target: https://en.wikipedia.org/wiki/DNS_Certification_Authority_Authorization + title: DNS Certification Authority Authorization + + CA: + target: https://en.wikipedia.org/wiki/Certificate_authority + title: Certificate Authority + + RFC5280: ICRL + + CRL: + target: https://en.wikipedia.org/wiki/Certificate_revocation_list + title: Certificate Revocation List + + RFC6960: OCSP + + OCSPW: + target: https://en.wikipedia.org/wiki/Online_Certificate_Status_Protocol + title: Online Certificate Status Protocol + + WOT: + target: https://en.wikipedia.org/wiki/Web_of_trust + title: Web of Trust + + CEDS: + target: "https://resources.infosecinstitute.com/cybercrime-exploits-digital-certificates/#gref" + title: “How Cybercrime Exploits Digital Certificates” + seriesinfo: "InfoSecInstitute" + date: 2014-07-28 + + KDDH: + target: "https://krebsonsecurity.com/2019/02/a-deep-dive-on-the-recent-widespread-dns-hijacking-attacks/" + title: A Deep Dive on the Recent Widespread DNS Hijacking Attacks + seriesinfo: "KrebsonSecurity" + date: 2019-02-19 + + DNSH: + target: "https://arstechnica.com/information-technology/2019/01/a-dns-hijacking-wave-is-targeting-companies-at-an-almost-unprecedented-scale/" + title: A DNS hijacking wave is targeting companies at an almost unprecedented scale + seriesinfo: "Ars Technica" + date: 2019-01-10 + author: + ins: D. Goodin + name: Dan Goodin + + SFTCA: + target: https://pdfs.semanticscholar.org/7876/380d71dd718a22546664b7fcc5b413c1fa49.pdf + title: "Search for Trust: An Analysis and Comparison of CA System Alternatives and Enhancements" + seriesinfo: "Dartmouth Computer Science Technical Report TR2012-716, 2012" + author: + ins: A. Grant + name: A. C. Grant + + DNSP: + target: https://www.thesslstore.com/blog/dns-poisoning-attacks-a-guide-for-website-admins/ + title: "DNS Poisoning Attacks: A Guide for Website Admins" + seriesinfo: "HashedOut" + date: 2020/01/21 + author: + ins: G. Stevens + name: G. Stevens + + BGPC: + target: https://petsymposium.org/2017/papers/hotpets/bgp-bogus-tls.pdf + title: Using BGP to acquire bogus TLS certificates + seriesinfo: "Workshop on Hot Topics in Privacy Enhancing Technologies, no. HotPETs 2017" + author: + ins: "H. Birge-Lee" + name: "H. Birge-Lee" + + BBGP: + target: "https://www.usenix.org/conference/usenixsecurity18/presentation/birge-lee" + title: "Bamboozling certificate authorities with BGP" + seriesinfo: "vol. 27th USENIX Security Symposium, no. 18, pp. 833-849, 2018" + author: + ins: "H. Birge-Lee" + name: "H. Birge-Lee" + + RFC6962: CT + + CTE: + target: https://certificate.transparency.dev + title: Certificate Transparency Ecosystem + + CTAOL: + target: https://queue.acm.org/detail.cfm?id=2668154 + title: "Certificate Transparency: Public, verifiable, append-only logs" + seriesinfo: "ACMQueue, vol. Vol 12, Issue 9" + date: 2014-09-08 + author: + ins: B. Laurie + name: B. Laurie + + RT: + target: https://www.links.org/files/RevocationTransparency.pdf + title: Revocation Transparency + + VDS: + target: https://github.com/google/trillian/blob/master/docs/papers/VerifiableDataStructures.pdf + title: Verifiable Data Structures + seriesinfo: "WhitePaper" + date: 2015-11-01 + + ESMT: + target: https://eprint.iacr.org/2016/683.pdf + title: Efficient sparse merkle trees + seriesinfo: "Nordic Conference on Secure IT Systems, pp. 199-215, 2016" + + RC: + target: https://en.wikipedia.org/wiki/Ricardian_contract + title: Ricardian Contract + + +--- abstract + +An identity system-based secure overlay for the Internet is presented. This is based on a Key Event Receipt Infrastructure (KERI) or the KERI protocol {{KERI}}{{KERI-ID}}{{RFC0791}}. This includes a primary root-of-trust in self-certifying identifiers (SCIDs) {{UIT}}{{SCPK}}{{SFS}}{{SCPN}}{{SCURL}}. It presents a formalism for Autonomic Identifiers (AIDs) and Autonomic Namespaces (ANs). They are part of an Autonomic Identity System (AIS). This system uses the design principle of minimally sufficient means to provide a candidate trust spanning layer for the internet. Associated with this system is a decentralized key management infrastructure (DKMI). The primary root-of-trust are self-certifying identifiers that are strongly bound at issuance to a cryptographic signing (public, private) keypair. These are self-contained until/unless control needs to be transferred to a new keypair. In that event, an append-only chained key-event log of signed transfer statements provides end verifiable control provenance. This makes intervening operational infrastructure replaceable because the event logs may be served up by any infrastructure including ambient infrastructure. End verifiable logs on ambient infrastructure enable ambient verifiability (verifiable by anyone, anywhere, at any time). +The primary key management operation is key rotation (transference) via a novel key pre-rotation scheme {{DAD}}{{KERI}}. Two primary trust modalities motivated the design, these are a direct (one-to-one) mode and an indirect (one-to-any) mode. The indirect mode depends on witnessed key event receipt logs (KERL) as a secondary root-of-trust for validating events. This gives rise to the acronym KERI for key event receipt infrastructure. In the direct mode, the identity controller establishes control via verified signatures of the controlling keypair. The indirect mode extends that trust basis with witnessed key event receipt logs (KERL) for validating events. The security and accountability guarantees of indirect mode are provided by KA2CE or KERI’s Agreement Algorithm for Control Establishment among a set of witnesses. +The KA2CE approach may be much more performant and scalable than more complex approaches that depend on a total ordering distributed consensus ledger. Nevertheless, KERI may employ a distributed consensus ledger when other considerations make it the best choice. The KERI approach to DKMI allows for more granular composition. Moreover, because KERI is event streamed it enables DKMI that operates in-stride with data events streaming applications such as web 3.0, IoT, and others where performance and scalability are more important. The core KERI engine is identifier namespace independent. This makes KERI a candidate for a universal portable DKMI {{KERI}}{{KERI-ID}}{{UIT}}. + + + + +--- middle + +# Introduction + +The main motivation for this work is to provide a secure decentralized foundation of attributional trust for the Internet as a trustable spanning layer in the form of an identifier system security overlay. This identifier system security overlay provides verifiable authorship (authenticity) of any message or data item via secure (cryptographically verifiable) attribution to a *cryptonymous (cryptographic strength pseudonymous)* *self-certifying identifier (SCID)* {{KERI}}{{UIT}}{{SCPK}}{{SFS}}{{SCPN}}{{SCURL}}{{PKI}}. + +A major flaw in the original design of the Internet Protocol was that it has no security layer(s) (i.e. Session or Presentation layers) to provide interoperable verifiable authenticity {{RFC0791}}. There was no built-in mechanism for secure attribution to the source of a packet. Specifically, the IP packet header includes a source address field that indicates the IP address of the device that sent the packet. Anyone (including any intermediary) can forge an IP (Internet Protocol) packet. Because the source address of such a packet can be undetectably forged, a recipient may not be able to ascertain when or if the packet was sent by an imposter. This means that secure attribution mechanisms for the Internet must be overlaid (bolted-on). KERI provides such a security overlay. We describe it as an identifier system security overlay. + +## Self-Certifying IDentifier (SCID) + +The KERI identifier system overlay leverages the properties of cryptonymous ***self-certifying identifiers*** (SCIDs) which are based on asymmetric public-key cryptography (PKI) to provide end-verifiable secure attribution of any message or data item without needing to trust in any intermediary {{PKI}}{{KERI}}{{UIT}}{{SCPK}}{{SFS}}{{SCPN}}{{SCURL}}. A self-certifying identifier (SCID) is uniquely cryptographically derived from the public key of an asymmetric keypair, `(public, private)`. It is self-certifying in the sense that does not rely on a trusted entity. Any non-repudiable signature made with the private key may be verified by extracting the public key from either the identifier itself or incepting information uniquely associated with the cryptographic derivation process for the identifier. In a basic SCID, the mapping between an identifier and its controlling public key is self-contained in the identifier itself. A basic SCID is *ephemeral* i.e. it does not support rotation of its keypairs in the event of key weakness or compromise and therefore must be abandoned once the controlling private key becomes weakened or compromised from exposure. The class of identifiers that generalize SCIDs with enhanced properties such as persistence is called *autonomic identifiers* (AIDs). + +## Autonomic IDentifier (AID) + +A Key Event Log (KEL) gives rise to an enhanced class of SCIDs that are persistent, not ephemeral, because their keys may be refreshed or updated via rotation allowing secure control over the identifier in spite of key weakness or even compromise. +This family of generalized enhanced SCIDs we call ***autonomic identifiers*** (AIDs). *Autonomic* means self-governing, self-regulating, or self-managing and is evocative of the self-certifying, self-managing properties of this class of identifier. + +## Key Pre-rotation Concept + +An important innovation of KERI is that it solves the key rotation problem of PKI (including that of simple self-certifying identifiers) via a novel but elegant mechanism we call ***key pre-rotation*** {{DAD}}{{KERI}}. This *pre-rotation* mechanism enables an entity to persistently maintain or regain control over an identifier in spite of the exposure-related weakening over time or even compromise of the current set of controlling (signing) keypairs. With key pre-rotation, control over the identifier can be re-established by rotating to a one-time use set of unexposed but pre-committed rotation keypairs that then become the current signing keypairs. Each rotation in turn cryptographically commits to a new set of rotation keys but without exposing them. Because the pre-rotated keypairs need never be exposed prior to their one-time use, their attack surface may be optimally minimized. The current key-state is maintained via an append-only ***verifiable data structure*** we call a ***key event log*** (KEL). + + +## Cryptographic Primitives + +### CESR + +A ***cryptographic primitive ***is a serialization of a value associated with a cryptographic operation including but not limited to a digest (hash), a salt, a seed, a private key, a public key, or a signature. All cryptographic primitives in KERI MUST be expressed using the CESR (Compact Event Streaming Representation) protocol {{CESR-ID}}. CESR supports round trip lossless conversion between its text, binary, and raw domain representations and lossless composability between its text and binary domain representations. Composability is ensured between any concatenated group of text primitives and the binary equivalent of that group because all CESR primitives are aligned on 24-bit boundaries. Both the text and binary domain representations are serializations suitable for transmission over the wire. The text domain representation is also suitable to be embedded as a string value of a field or array element as part of a field map serialization such as JSON, CBOR, or MsgPack {{RFC8259}}{{JSOND}}{{RFC8949}}{{CBORC}}{{MGPK}}. The text domain uses the set of characters from the URL-safe variant of Base64 which in turn is a subset of the ASCII character set {{RFC4648}}{{RFC0020}}. For the sake of readability, all examples in this specification will be expressed in CESR's text-domain. + +### Qualified Cryptographic Primitive + +When *qualified*, a cryptographic primitive includes a prepended derivation code (as a proem) that indicates the cryptographic algorithm or suite used for that derivation. This simplifies and compactifies the essential information needed to use that cryptographic primitive. All cryptographic primitives expressed in either text or binary CESR are *qualified* by definition. Qualification is an essential property of CESR {{CESR-ID}}. The CESR protocol supports several different types of encoding tables for different types of derivation codes. These tables include very compact codes. For example, a 256-bit (32-byte) digest using the BLAKE3 digest algorithm, i.e. Blake3-256, when expressed in text-domain CESR is 44 Base64 characters long and begins with the one character derivation code `E`, such as, `EL1L56LyoKrIofnn0oPChS4EyzMHEEk75INJohDS_Bug` {{BLAKE3}}{{BLAKE3Spec}}{{BLAKE3Hash}}. The equivalent *qualified* binary domain representation is 33 bytes long. +Unless otherwise indicated, all cryptographic primitives in this specification will appear as *qualified* primitives using text-domain CESR. + +## Identifier System Security Overlay + +The function of KERI's identifier-system security overlay is to establish the authenticity (or authorship) of the message payload in an IP Packet by verifiably attributing it to a cryptonymous self-certifying identifier (AID) via an attached set of one or more asymmetric keypair-based non-repudiable digital signatures. The current valid set of associated asymmetric keypair(s) is proven via a verifiable data structure called a ***key event log*** (KEL) {{KERI}}{{VDS}}{{ESMT}}{{RT}}. The identifier system provides a mapping between the identifier and the keypair(s) that control the identifier, namely, the public key(s) from those keypairs. The private key(s) is secret and is not shared. + +An authenticatable (verifiable) internet message (packet) or data item includes the identifier and data in its payload. Attached to the payload is a digital signature(s) made with the private key(s) from the controlling keypair(s). Given the identifier in a message, any verifier of a message (data item) can use the identifier system mapping to look up the public key(s) belonging to the controlling keypair(s). The verifier can then verify the attached signature(s) using that public key(s). Because the payload includes the identifier, the signature makes a non-repudiable cryptographic commitment to both the source identifier and the data in the payload. + +### Security Overlay Flaws + +There are two major flaws in conventional PKI-based identifier system security overlays (including the Internet's DNS/CA system) {{PKI}}{{DNS}}{{RFC0799}}{{CAA}}{{CA}}{{RFC5280}}. + +The *first major flaw** is that the mapping between the identifier (domain name) and the controlling keypair(s) is merely asserted by a trusted entity e.g. certificate authority (CA) via a certificate. Because the mapping is merely asserted, a verifier can not cryptographically verify the mapping between the identifier and the controlling keypair(s) but must trust the operational processes of the trusted entity making that assertion, i.e. the CA who issued and signed the certificate. As is well known, a successful attack upon those operational processes may fool a verifier into trusting an invalid mapping i.e. the certificate is issued to the wrong keypair(s) albeit with a verifiable signature from a valid certificate authority. {{CEDS}}{{KDDH}}{{DNSH}}{{SFTCA}}{{DNSP}}{{BGPC}}{{BBGP}}. Noteworthy is that the signature on the certificate is NOT made with the controlling keypairs of the identifier but made with keypairs controlled by the issuer i.e. the CA. The fact that the certificate is signed by the CA means that the mapping itself is not verifiable but merely that the CA asserted the mapping between keypair(s) and identifier. The certificate merely provides evidence of the authenticity of the assignment of the mapping but not evidence of the veracity of the mapping. + +The *second major flaw* is that when rotating the valid signing keys there is no cryptographically verifiable way to link the new (rotated in) controlling/signing key(s) to the prior (rotated out) controlling/signing key(s). Key rotation is merely implicitly asserted by a trusted entity (CA) by issuing a new certificate with new controlling/signing keys. Key rotation is necessary because over time the controlling keypair(s) of an identifier becomes weak due to exposure when used to sign messages and must be replaced. An explicit rotation mechanism first revokes the old keys and then replaces them with new keys. Even a certificate revocation list (CRL) as per RFC5280, with an online status protocol (OCSP) registration as per RFC6960, does not provide a cryptographically verifiable connection between the old and new keys, it is merely asserted {{RFC5280}}{{RFC6960}}{{OCSPW}}. The lack of a single universal CRL or registry means that multiple potential replacements may be valid. From a cryptographic verifiability perspective, rotation by assertion with a new certificate that either implicitly or explicitly provides revocation and replacement is essentially the same as starting over by creating a brand new independent mapping between a given identifier and the controlling keypair(s). This start-over style of key rotation may well be one of the main reasons that PGP's web-of-trust failed {{WOT}}. Without a universally verifiable revocation mechanism, then any rotation (revocation and replacement) assertions either explicit or implicit are mutually independent of each other. This lack of universal cryptographic verifiability of a rotation fosters ambiguity at any point in time as to the actual valid mapping between the identifier and its controlling keypair(s). In other words, for a given identifier, any or all assertions made by some set of CAs may be potentially valid. + +We call the state of the controlling keys for an identifier at any time the key state. Cryptographic verifiability of the key state over time is essential to remove this ambiguity. Without this verifiability, the detection of potential ambiguity requires yet another bolt-on security overlay such as the certificate transparency system {{CTE}}{{CTAOL}}{{RFC6962}}{{RT}}{{VDS}}{{ESMT}}. + +The KERI protocol fixes both of these flaws using a combination of ***autonomic identifiers***, ***key pre-rotation***, a ***verifiable data structure*** (VDS) called a KEL as verifiable proof of key-state, and ***duplicity-evident*** mechanisms for evaluating and reconciling key state by validators {{KERI}}. Unlike certificate transparency, KERI enables the detection of duplicity in the key state via non-repudiable cryptographic proofs of duplicity not merely the detection of inconsistency in the key state that may or may not be duplicitous {{KERI}}{{CTAOL}}. + + +### Triad Bindings + +In simple form an identifier-system security-overlay binds together a triad consisting of the ***identifier***, ***keypairs***, and ***controllers***. By ***identifier*** we mean some string of characters. By ***keypairs*** we mean a set of asymmetric (public, private) cryptographic keypairs used to create and verify non-repudiable digital signatures. By ***controllers*** we mean the set of entities whose members each control a private key from the given set of ***keypairs***. When those bindings are strong then the overlay is highly *invulnerable* to attack. In contrast, when those bindings are weak then the overlay is highly *vulnerable* to attack. The bindings for a given identifier form a *triad* that binds together the set of *controllers*, the set of *keypairs*, and the *identifier*. To reiterate, the set of controllers is bound to the set of keypairs, the set of keypairs is bound to the identifier, and the identifier is bound to the set of controllers. This binding triad can be diagrammed as a triangle where the sides are the bindings and the vertices are the *identifier*, the set of *controllers*, and the set of *keypairs*. This triad provides verifiable ***control authority*** for the identifier. + +With KERI all the bindings of the triad are strong because they are cryptographically verifiable with a minimum cryptographic strength or level of approximately 128 bits. See the Appendix on cryptographic strength for more detail. + +The bound triad is created as follows\: + +* Each controller in the set of controllers creates an asymmetric `(pubic, private)` keypair. The public key is derived from the private key or seed using a one-way derivation that MUST have a minimum cryptographic strength of approximately 128 bits {{OWF}}{{COWF}}. Depending on the crypto-suite used to derive a keypair the private key or seed may itself have a length larger than 128 bits. A controller may use a cryptographic strength pseudo-random number generator (CSPRNG) {{CSPRNG}} to create the private key or seed material. Because the private key material must be kept secret, typically in a secure data store, the management of those secrets may be an important consideration. One approach to minimize the size of secrets is to create private keys or seeds from a secret salt. The salt MUST have an entropy of approximately 128 bits. The salt may then be stretched to meet the length requirements for the crypto suite's private key size {{Salt}}{{Stretch}}. In addition, a hierarchical deterministic derivation function may be used to further minimize storage requirements by leveraging a single salt for a set or sequence of private keys {{HDKC}}. Because each controller is the only entity in control (custody) of the private key, and the public key is universally uniquely derived from the private key using a cryptographic strength one-way function, then the binding between each controller and their keypair is as strong as the ability of the controller to keep that key private {{OWF}}{{COWF}}. The degree of protection is up to each controller to determine. For example, a controller could choose to store their private key in a safe, at the bottom of a coal mine, air-gapped from any network, with an ex-special forces team of guards. Or the controller could choose to store it in an encrypted data store (key chain) on a secure boot mobile device with a biometric lock, or simply write it on a piece of paper and store it in a safe place. The important point is that the strength of the binding between controller and keypair does not need to be dependent on any trusted entity. + +* The identifier is universally uniquely derived from the set of public keys using a one-way derivation function {{OWF}}{{COWF}}. It is therefore an AID (qualified SCID). Associated with each identifier (AID) is incepting information that MUST include a list of the set of *qualified* public keys from the controlling keypairs. In the usual case, the identifier is a *qualified* cryptographic digest of the serialization of all the incepting information for the identifier. Any change to even one bit of the incepting information changes the digest and hence changes the derived identifier. This includes any change to any one of the qualified public keys including its qualifying derivation code. To clarify, a *qualified* digest as identifier includes a derivation code as proem that indicates the cryptographic algorithm used for the digest. Thus a different digest algorithm results in a different identifier. In this usual case, the identifier is strongly cryptographically bound to not only the public keys but also any other incepting information from which the digest was generated. + +A special case may arise when the set of public keys has only one member, i.e. there is only one controlling keypair. In this case, the controller of the identifier may choose to use only the *qualified* public key as the identifier instead of a *qualified* digest of the incepting information. In this case, the identifier is still strongly bound to the public key but is not so strongly bound to any other incepting information. A variant of this single keypair special case is an identifier that can not be rotated. Another way of describing an identifier that cannot be rotated is that it is a *non-transferable* identifier because control over the identifier cannot be transferred to a different set of controlling keypairs. Whereas a rotatable keypair is *transferable* because control may be transferred via rotation to a new set of keypairs. Essentially, when non-transferable, the identifier's lifespan is *ephemeral*, not *persistent*, because any weakening or compromise of the controlling keypair means that the identifier must be abandoned. Nonetheless, there are important use cases for an *ephemeral* self-certifying identifier. In all cases, the derivation code in the identifier indicates the type of identifier, whether it be a digest of the incepting information (multiple or single keypair) or a single member special case derived from only the public key (both ephemeral or persistent). + +* Each controller in a set of controllers is may prove its contribution to the control authority over the identifier in either an interactive or non-interactive fashion. One form of interactive proof is to satisfy a challenge of that control. The challenger creates a unique challenge message. The controller responds by non-repudiably signing that challenge with the private key from the keypair under its control. The challenger can then cryptographically verify the signature using the public key from the controller's keypair. One form of non-interactive proof is to periodically contribute to a monotonically increasing sequence of non-repudiably signed updates of some data item. Each update includes a monotonically increasing sequence number or date-time stamp. Any observer can then cryptographically verify the signature using the public key from the controller's keypair and verify that the update was made by the controller. In general, only members of the set of controllers can create verifiable non-repudiable signatures using their keypairs. Consequently, the identifier is strongly bound to the set of controllers via provable control over the keypairs. + +*** Tetrad Bindings + +At inception, the triad of identifier, keypairs, and controllers are strongly bound together. But in order for those bindings to persist after a key rotation, another mechanism is required. That mechanism is a verifiable data structure called a *key event log* (KEL) {{KERI}}{{VDS}}. The KEL is not necessary for identifiers that are non-transferable and do not need to persist control via key rotation in spite of key weakness or compromise. To reiterate, transferable (persistent) identifiers each need a KEL, non-transferable (ephemeral) identifiers do not. + +For persistent (transferable) identifiers, this additional mechanism may be bound to the triad to form a tetrad consisting of the KEL, the identifier, the set of keypairs, and the set of controllers. The first entry in the KEL is called the *inception event* which is a serialization of the incepting information associated with the identifier mentioned previously. + +The *inception event* MUST include the list of controlling public keys. It MUST also include a signature threshold and MUST be signed by a set of private keys from the controlling keypairs that satisfy that threshold. Additionally, for transferability (persistence across rotation) the *inception event* MUST also include a list of digests of the set of pre-rotated public keys and a pre-rotated signature threshold that will become the controlling (signing) set of keypairs and threshold after a rotation. A non-transferable identifier MAY have a trivial KEL that only includes an *inception event* but with a null set (empty list) of pre-rotated public keys. + +A rotation is performed by appending to the KEL a *rotation event*. A *rotation event* MUST include a list of the set of pre-rotated public keys (not their digests) thereby exposing them and MUST be signed by a set of private keys from these newly exposed newly controlling but pre-rotated keypairs that satisfy the pre-rotated threshold. The rotation event MUST also include a list of the digests of a new set of pre-rotated keys as well as the signature threshold for the set of pre-rotated keypairs. At any point in time the transferability of an identifier can be removed via a *rotation event* that rotates to a null set (empty list) of pre-rotated public keys. + +Each event in a KEL MUST include an integer sequence number that is one greater than the previous event. Each event after the inception event MUST also include a cryptographic digest of the previous event. This digest means that a given event is cryptographically bound to the previous event in the sequence. The list of digests or pre-rotated keys in the inception event cryptographically binds the inception event to a subsequent rotation event. Essentially making a forward commitment that forward chains together the events. The only valid rotation event that may follow the inception event must include the pre-rotated keys. But only the controller who created those keys and created the digests may verifiably expose them. Each rotation event in turn makes a forward commitment (chain) to the following rotation event via its list of pre-rotated key digests. This makes the KEL a doubly (backward and forward) hash (digest) chained non-repudiably signed append-only verifiable data structure. + +Because the signatures on each event are non-repudiable, the existence of an alternate but verifiable KEL for an identifier is provable evidence of duplicity. In KERI, there may be at most one valid KEL for any identifier or none at all. Any validator of a KEL may enforce this one valid KEL rule before relying on the KEL as proof of the current key state for the identifier. This protects the validator. Any unreconcilable evidence of duplicity means the validator does not trust (rely on) any KEL to provide the key state for the identifier. Rules for handling reconciliable duplicity will be discussed later. From a validator's perspective, either there is one-and-only-one valid KEL or none at all. This protects the validator. This policy removes any potential ambiguity about key state. The combination of a verifiable KEL made from non-repudiably signed backward and forward hash chained events together with the only-one-valid KEL rule strongly binds the identifier to its current key-state as given by that one valid KEL or not at all. This in turn binds the identifier to the controllers of the current keypairs given by the KEL thus completing the tetrad. + +At inception, the KEL may be even more strongly bound to its tetrad by deriving the identifier from a digest of the *inception event*. Thereby even one change in not only the original controlling keys pairs but also the pre-rotated keypairs or any other incepting information included in the *inception event* will result in a different identifier. + +The essense of the KERI protocol is a strongly bound tetrad of identifier, keypairs, controllers, and key event log that forms the basis of its identifier system security overlay. The KERI protocol introduces the concept of duplicity evident programming via duplicity evident verifiable data structures. The full detailed exposition of the protocol is provided in the following sections. + +# Basic Terminology + +Several new terms were introduced above. These along with other terms helpful to describing KERI are defined below. + +Primitive +: A serialization of a unitary value. A *cryptographic primitive* is the serialization of a value associated with a cryptographic operation including but not limited to a digest (hash), a salt, a seed, a private key, a public key, or a signature. All *primitives* in KERI MUST be expressed in CESR (Compact Event Streaming Representation) {{CESR-ID}}. + +Qualified +: When *qualified*, a *cryptographic primitive* includes a prepended derivation code (as a proem) that indicates the cryptographic algorithm or suite used for that derivation. This simplifies and compactifies the essential information needed to use that *cryptographic primitive*. All *cryptographic primitives* expressed in either text or binary CESR are *qualified* by definition {{CESR-ID}}. Qualification is an essential property of CESR {{CESR-ID}}. + +Cryptonym +: A cryptographic pseudonymous identifier represented by a string of characters derived from a random or pseudo-random secret seed or salt via a one-way cryptogrphic function with a sufficiently high degree of cryptographic strength (e.g. 128 bits, see appendix on cryptographic strength) {{OWF}}{{COWF}}{{TMCrypto}}{{QCHC}}. A *cryptonym* is a type of *primitive*. Due the enctropy in its derivation, a *cryptonym* is a universally unique identifier and only the controller of the secret salt or seed from which the *cryptonym* is derived may prove control over the *cryptonym*. Therefore the derivation function MUST be associated with the *cryptonym* and MAY be encoded as part of the *cryptonym* itself. + +SCID +: Self-Certifying IDentifier. A self-certifying identifier (SCID) is a type of cryptonym that is uniquely cryptographically derived from the public key of an asymmetric non-repudiable signing keypair, `(public, private)`. It is self-certifying or more precisely self-authenticating because it does not rely on a trusted entity. The authenticity of a non-repudiable signature made with the private key may be verified by extracting the public key from either the identifier itself or incepting information uniquely associated with the cryptographic derivation process for the identifier. In a basic SCID, the mapping between an identifier and its controlling public key is self-contained in the identifier itself. A basic SCID is *ephemeral* i.e. it does not support rotation of its keypairs in the event of key weakness or compromise and therefore must be abandoned once the controlling private key becomes weakened or compromised from exposure {{PKI}}{{KERI}}{{UIT}}{{SCPK}}{{SFS}}{{SCPN}}{{SCURL}}{{PKI}}. + +AID +: Autonomic IDentifier. A self-managing *cryptonymous* identifier that MUST be self-certifying (self-authenticating) and MUST be encoded in CESR as a *qualified* cryptographic primitive. An AID MAY exhibit other self-managing properties such as transferable control using key *pre-rotation* which enables control over such an AID to persist in spite of key weakness or compromise due to exposure. Authoritative control over the identifier persists in spite of the evolution of the key-state. + +Key State +: Includes the set of currently authoritative keypairs for an AID and any other information necessary to secure or establish control authority over an AID. + +Key Event +: Concretely, the serialized data structure of an entry in the key event log for an AID. Abstractly, the data structure itself. Key events come in different types and are used primarily to establish or change the authoritative set of keypairs and/or anchor other data to the authoritative set of keypairs at the point in the key event log actualized by a particular entry. + +Establishment Event +: Key Event that establishes or changes the key-state which includes the current set of authoritative keypairs (key-state) for an AID. + +Non-establishment Event +: Key Event that does not change the current key-state for an AID. Typically the purpose of a non-establishment event is to anchor external data to a given key state as established by the most recent prior establishment event for an AID. + +Inception Event +: Establishment Event that provides the incepting information needed to derive an AID and establish its initial key-state. + +Inception +: The operation of creating an AID by binding it to the initial set of authoritative keypairs and any other associated information. This operation is made verifiable and duplicity evident upon acceptance as the *inception event* that begins the AID's KEL. + +Rotation Event +: Establishment Event that provides the information needed to change the key-state which includes a change to the set of authoritative keypairs for an AID. + +Rotation +: The operation of revoking and replacing the set of authoritative keypairs for an AID. This operation is made verifiable and duplicity evident upon acceptance as a *rotation event* that is appended to the AID's KEL. + +Interaction Event +: Non-establishment Event that anchors external data to the key-state as established by the most recent prior establishment event. + +KEL +: Key Event Log. A verifiable data structure that is a backward and forward chained, signed, append-only log of key events for an AID. The first entry in a KEL MUST be the one and only Inception Event of that AID. + +Version +: More than one version of a KEL for an AID exists when for any two instances of a KEL at least one event is unique between the two instances. + +Verifiable +: A KEL is verifiable means all content in a KEL including the digests and the signatures on that content is verifiably compliant with respect to the KERI protocol. In other words, the KEL is internally consistent and has integrity vis-a-vis its backward and forward chaining digests and authenticity vis-a-vis its non-repudiable signatures. As a verifiable data structure, the KEL satisfies the KERI protocol-defined rules for that verifiability. This includes the cryptographic verification of any digests or signatures on the contents so digested or signed. + +Duplicity +: Means the existence of more than one version of a verifiable KEL for a given AID. Because every event in a KEL must be signed with non-repudiable signatures any inconsistency between any two instances of the KEL for a given AID is provable evidence of duplicity on the part of the signers with respect to either or both the key-state of that AID and/or any anchored data at a given key-state. A shorter KEL that does not differ in any of its events with respect to another but longer KEL is not duplicitous but merely incomplete. To clarify, duplicity evident means that duplicity is provable via the presentation of a set of two or more mutually inconsistent but independently verifiable instances of a KEL. + +Verifier : Any entity or agent that cryptographically verifies the signature(s) and/or digests on an event message. In order to verify a signature, a verifier must first determine which set of keys are or were the controlling set for an identifier when an event was issued. In other words, a verifier must first establish control authority for an identifier. For identifiers that are declared as non-transferable at inception, this control establishment merely requires a copy of the inception event for the identifier. For identifiers that are declared transferable at inception, this control establishment requires a complete copy of the sequence of establishment events (inception and all rotations) for the identifier up to the time at which the statement was issued. + +Validator +: Any entity or agent that evaluates whether or not a given signed statement as attributed to an identifier is valid at the time of its issuance. A valid statement MUST be verifiable, that is, has a verifiable signature from the current controlling keypair(s) at the time of its issuance. Therefore a *Validator* must first act as a *Verifier* in order to establish the root authoritative set of keys. Once verified, the *Validator* may apply other criteria or constraints to the statement in order to determine its validity for a given use case. When that statement is part of a verifiable data structure then the cryptographic verification includes verifying digests and any other structural commitments or constraints. To elaborate, with respect to an AID, for example, a *Validator* first evaluates one or more KELs in order to determine if it can rely on (trust) the key state (control authority) provided by any given KEL. A necessary but insufficient condition for a valid KEL is it is verifiable i.e. is internally inconsistent with respect to compliance with the KERI protocol. An invalid KEL from the perspective of a Validator may be either unverifiable or may be verifiable but duplicitous with respect to some other verifiable version of that KEL. Detected duplicity by a given validator means that the validator has seen more than one verifiable version of a KEL for a given AID. Reconciliable duplicity means that one and only one version of a KEL as seen by a Validator is accepted as the authoritative version for that validator. Irreconcilable duplicity means that none of the versions of a KEL as seen by a validator are accepted as the authoritative one for that validator. The conditions for reconcilable duplicity are described later. + +Message +: Consists of a serialized data structure that comprises its body and a set of serialized data structures that are its attachments. Attachments may include but are not limited to signatures on the body. + +Key Event Message +: Message whose body is a key event and whose attachments may include signatures on its body. + +Key Event Receipt +: Message whose body references a key event and whose attachments MUST include one or more signatures on that key event. + + +# Keypair Labeling Convention + +In order to make key event expressions both clearer and more concise, we use a keypair labeling convention. When an AID's key state is dynamic, i.e. the set of controlling keypairs is transferable, then the keypair labels are indexed in order to represent the successive sets of keypairs that constitute the key state at any position in the KEL (key event log). To elaborate, we use indexes on the labels for AIDs that are transferable to indicate which set of keypairs is associated with the AID at any given point in its key state or KEL. In contrast, when the key state is static, i.e. the set of controlling keypairs is non-transferable then no indexes are needed because the key state never changes. + +Recall that, a keypair is a two tuple, *(public, private)*, of the respective public and private keys in the keypair. For a given AID, the labeling convention uses an uppercase letter label to represent that AID. When the key state is dynamic, a superscripted index on that letter is used to indicate which keypair is used at a given key state. Alternatively, the index may be omitted when the context defines which keypair and which key state, such as, for example, the latest or current key state. To reiterate, when the key state is static no index is needed. + +In general, without loss of specificity, we use an uppercase letter label to represent both an AID and when indexed to represent its keypair or keypairs that are authoritative at a given key state for that AID. In addition, when expressed in tuple form the uppercase letter also represents the public key and the lowercase letter represents the private key for a given keypair. For example, let *A* denote and AID, then let* A* also denote a keypair which may be also expressed in tuple form as *(A, a)*. Therefore, when referring to the keypair itself as a pair and not the individual members of the pair, either the uppercase label, *A*, or the tuple, *(A, a)*, may be used to refer to the keypair itself. When referring to the individual members of the keypair then the uppercase letter, *A*, refers to the public key, and the lowercase letter, *a*, refers to the private key. + +Let the sequence of keypairs that are authoritative (i.e establish control authority) for an AID be indexed by the zero-based integer-valued, strictly increasing by one, variable *i*. Furthermore, as described above, an establishment key event may change the key state. Let the sequence of establishment events be indexed by the zero-based integer-valued, strictly increasing by one, variable *j*. When the set of controlling keypairs that are authoritative for a given key state includes only one member, then *i = j* for every keypair, and only one index is needed. But when the set of keypairs used at any time for a given key state includes more than one member, then *i != j* for every keypair, and both indices are needed. + +In the former case, where only one index is needed because *i = j*, let the indexed keypair for AID, *A*, be denoted by *Ai* or in tuple form by *(Ai, ai)* where the keypair so indexed uses the *ith* keypair from the sequence of all keypairs. The keypair sequence may be expressed as the list, *[A0, A1, A2, ...]*. The zero element in this sequence is denoted by *A0* or in tuple form by *(A0, a0)*. + +In the latter case, where both indices are needed because *i != j*, let the indexed keypair for AID, *A*, be denoted by *Ai,j* or in tuple form by *(Ai,j, ai,j)* where the keypair so indexed is authoritative or potentially authoritative for *ith* keypair from the sequence of all keypairs that is authoritative in the the *jth* key state. Suppose, for example, that for a given AID labeled *A* each key state uses three keypairs to establish control authority, then the sequence of the first two key states will consume the first six keypairs as given by the following list, *[A0,0, A1,0, A2,0, A3,1, A4,1, A5,1]*. + +Furthermore, with pre-rotation, each public key from the set of pre-rotated keypairs may be hidden as a qualified cryptographic digest of that public key. The digest of the public key labeled *A* is represented using the functional notation *H(A)* for hash (digest). When singly indexed, the digest of *Ai* is denoted by *H(Ai)* and when doubly indexed the digest of *Ai,j* is denoted by *H(Ai,j}*. A pre-rotated keypair is potentially authoritative for the next or subsequent establishment event after the establishment event when the digest of the pre-rotated keypair first appears. Therefore its *jth* index value is one greater than the *jth* index value of the establishment event in which its digest first appears. As explained in more detail below, for partial rotation of a pre-rotated set, a pre-rotated keypair from a set of two or more pre-rotated keypairs is only potentially authoritative so that its actual authoritative *jth* index may change when it is actually rotated in if ever. + +Finally, each key event in a KEL MUST have a zero-based integer-valued, strictly increasing by one, sequence number. Abstractly we may use the variable *k* as an index on any keypair label to denote the sequence number of an event for which that keypair is authoritative. Usually, this appears as a subscript. Thus any given keypair label could have three indices, namely, *i,j,k* that appear as follows, *Ai,jk* where *i* denotes the *ith* keypair from the sequence of all keypairs, *j* denotes the *jth establishment event in which the keypair is authoritative, and *k* represents the *kth* key event in which the keypair is authoritative. When a KEL has only establishment events then *j = k*. + +# Pre-rotation Detail + +Each establishment event involves two sets of keys that each play a role that together establishes complete control authority over the AID associated at the location of that event in the KEL. To clarify, control authority is split between keypairs that hold signing authority and keypairs that hold rotation authority. A rotation revodes and replaces the keypairs that hold signing authority as well as replacing the keypairs that hold rotation authority. The two set sets of keys are labeled *current* and *next*. Each establishment event designates both sets of keypairs. The first (*current*) set consists of the authoritative signing keypairs bound to the AID at the location in the KEL where the establishment event occurs. The second (*next*) set consists of the pre-rotated authoritative rotation keypairs that will be actualized in the next (ensuing) establishment event. Each public key in the set of next (ensuing) pre-rotated public keys is hidden in or blinded by a digest of that key. When the establishment event is the inception event then the *current* set is the *initial* set. The pre-rotated *next* set of Rotation keypairs are one-time use only rotation keypairs, but MAY be repurposed as signing keypairs after their one time use to rotate. + +In addition, each establishment event designates two threshold expressions, one for each set of keypairs (*current* and *next*). The *current* threshold determines the needed satisficing subset of signatures from the associated *current* set of keypairs for signing authority to be considered valid. The *next* threshold determines the needed satisficing subset of signatures from the associated *next* set of hidden keypairs for rotation authority to be considered valid. The simplest type of threshold expression for either threshold is an integer that is no greater than nor no less than the number of members in the set. An integer threshold acts as an *M of N* threshold where *M* is the threshold and *N* is the total number of keypairs represented by the public keys in the key list. If any set of *M* of the *N* private keys belonging to the public keys in the key list verifiably signs the event then the threshold is satisfied for the control authority role (signing or rotation) associated with the given key list and threshold . + +To clarify, each establishment event MUST include a list (ordered) of the qualified public keys from each of the current (initial) set of keypairs), a threshold for the current set, a list (ordered) of the qualified cryptographic digests of the qualified public keys from the next set of keypairs, and a threshold for the next set. Each event MUST also include the AID itself as either a qualified public key or a qualified digest of the inception establishment event. + +Each non-establishment event MUST be signed by a threshold-satisficing subset of private keys from the *current* set of keypairs from the most recent establishment event. A little more explanation is needed to understand the requirements for a valid set of signatures for each type of establishment event. + +## Inception Event Pre-rotation + +The creator of the inception event MUST create two sets of keypairs, the *current* (*initial*) set, and the *next* set. The private keys from the current set are kept as secrets. The public keys from the *current* set are exposed via inclusion in the inception event. Both the public and private keys from the *next* set are kept as secrets and only the cryptographic digests of the public keys from the *next* set are exposed via inclusion in the event. The public keys from the *next* set are only exposed in a subsequent establishment if any. Both thresholds are exposed via inclusion in the event. + +Upon emittance of the inception event, the *current* (*initial*) set of keypairs becomes the current set of verifiable authoritative signing keypairs for the identifier. Emittance of the inception event also issues the identifier. Moreover, to be verifiably authoritative, the inception event must be signed by a threshold satisficing subset of the *current* (*initial*) set of private keys. The inception event may be verified against the attached signatures using the included *current* (*initial*) list of public keys. When self-addressing, a digest of the serialization of the inception event provides the AID itself as derived by the SAID protocol {{SAID-ID}}. + +There MUST be only one inception establishment event. All subsequent establishment events MUST be rotation events. + +## Rotation Using Pre-rotation + +Unlike inception, the creator of a rotation event MUST create only one set of keypairs, the newly *next* set. Both the public and private keys from the newly *next* set are kept as secrets and only the cryptographic digests of the public keys from the newly *next* set are exposed via inclusion in the event. The list of newly *current* public keys MUST include the an old *next* threshold satisficing subset of old *next* public keys from the most recent prior establishment event. For short, we denote the next threshold from the most recent prior establishment event as the *prior next* threshold, and the list of unblinded public keys taken from the blinded key digest list from the most recent prior establishment event as the *prior next* key list. The subset of old *prior next* keys that are included in the newly current set of public keys MUST be unhidden or unblinded because they appear as the public keys themselves and no longer appear as digests of the public keys. Both thresholds are exposed via inclusion in the event. + +Upon emittance of the rotation event, the newly *current* keypairs become the *current* set of verifiable authoritative signing keypairs for the identifier. The old *current* set of keypairs from the previous establishment event has been revoked and replaced by the newly *current* set. Moreover, to be verifiably authoritative, the rotation event must be signed by a dual threshold satisficing subset of the newly *current* set of private keys. To elaborate, the set of signatures on a rotation event MUST satisfy two thresholds. These are the newly *current* threshold and the old *prior next* threshold from the most recent prior establishment event. Therefore the newly *current* set of public keys must include a satisfiable subset with respect to the old *prior next* threshold of public keys from the old *prior next* key list. The included newly *current* list of public keys enables verification of the rotation event against the attached signatures. + +The act of inclusion in each establishment event of the digests of the new *next* set of public keys performs a pre-rotation operation on that set by making a verifiable forward blinded commitment to that set. Consequently, no other set may be used to satisfy the threshold for the *next* rotation operation. Because the *next* set of pre-rotated keys is blinded (i.e. has not been exposed i.e. used to sign or even published) an attacker can't forge and sign a verifiable rotation operation without first unblinding the pre-rotated keys. Therefore, given sufficient cryptographic strength of the digests, the only attack surface available to the adversary is a side-channel attack on the private key store itself and not on signing infrastructure. But the creator of the pre-rotated private keys is free to make that key store as arbitrarily secure as needed because the pre-rotated keys are not used for signing until the next rotation. In other words, as long as the creator keeps secret the pre-rotated public keys themselves, an attacker must attack the key storage infrastructure because side-channel attacks on signing infrastructure are obviated. + +As explained later, for a validator, the first seen rule applies, that is, the first seen version of an event is the authoritative one for that validator. The first seen wins. In other words the first published becomes the first seen. Upon rotation, the old prior *next* keys are exposed but only after a new *next* set has been created and stored. Thus the creator is always able to stay one step ahead of an attacker. By the time a new rotation event is published, it is too late for an attacker to create a verifiable rotation event to supplant it because the orginal version has already been published and may be first seen by the validator. The window for an attacker is the network latency for the first published event to be first seen by the network of validators. Any later key compromise is too late. + +In essence, each key set follows a rotation lifecycle where it changes its role with each rotation event. A pre-rotated keypair set starts as the member of the *next* key set holding one-time rotation control authority. On the ensuing rotation that keypair becomes part of the the *current* key set holding signing control authority. Finally on the following rotation that keypair is discarded. The lifecycle for the initial key set in an inception event is slightly different. The initial key set starts as the *current* set holding signing authority and is discarded on the ensuing rotation event if any. + +## Pre-Rotation Example + +Recall that the keypairs for a given AID may be represented by the indexed letter label such as *Ai,jk* where *i* denotes the *ith* keypair from the sequence of all keypairs, *j* denotes the *jth establishment event in which the keypair is authoritative, and *k* represents the *kth* key event in which the keypair is authoritative. When a KEL has only establishment events then *j = k*. When only one keypair is authoritative at any given key state then *i = j*. + +Also, recall that a pre-rotated keypair is designated by the digest of its public key appearing in an establishment event. The digest is denoted as *H(A)* or *H(Ai,jk)* in indexed form. The appearance of the digest makes a forward verifiable cryptographic commitment that may be realized in the future when and if that public key is exposed and listed as a current authoritative signing key in a subsequent establishment event. + +The following example illustrates the lifecycle roles of the key sets drawn from a sequence of keys used for three establishment events; one inception followed by two rotations. The initial number of authoritative keypairs is three and then changes to two and then changes back to three. + +|Event| Current Keypairs | CT | Next Keypairs| NT | +|:-:|--:|--:|--:|--:| +|0| *[A0,0, A1,0, A2,0]* | 2 | *[H(A3,1), H(A4,1)]* | 1 | +|1| *[A3,1, A4,1]* | 1 | *[H(A5,2), H(A6,2), H(A7,2)]* | 2 | +|2| *[A5,2, A6,2, A7,2]* | 2 | *[H(A8,3), H(A9,3), H(A10,3]* | 2 | + ++ *CTH* means Current Threshold. + ++ *NTH* means Next Threshold. + +## Reserve Rotation + +The pre-rotation mechanism supports partial pre-rotation or more exactly partial rotation of pre-rotated keypairs. One important use case for partial rotation is to enable pre-rotated keypairs designated in one establishment event to be held in reserve and not exposed at the next (immediately subsequent) establishment event. This reserve feature enables keypairs held by controllers as members of a set of pre-rotated keypairs to be used for the purpose of fault tolerance in the case of non-availability by other controllers while at the same time minimizing the burden of participation by the reserve members. In other words, a reserved pre-rotated keypair contributes to the potential availability and fault tolerance of control authority over the AID without necessarily requiring the participation of the reserve key-pair in a rotation until and unless it is needed to provide continuity of control authority in the event of a fault (non-availability of a non-reserved member). This reserve feature enables different classes of key controllers to contribute to the control authority over an AID. This enables provisional key control authority. For example, a key custodial service or key escrow service could hold a keypair in reserve to be used only upon satisfaction of the terms of the escrow agreement. This could be used to provide continuity of service in the case of some failure event. Provisional control authority may be used to prevent types of common-mode failures without burdening the provisional participants in the normal non-failure use cases. + +## Custorial Rotation + +Partial pre-rotation supports another important use case that of custodial key rotation. Because control authority is split between two key sets, the first for signing authority and the second (pre-roateted) for rotation authority the associated thresholds and key list can be structured in such a way that a designated custodial agent can hold signing authority while the original controller can hold exclusive rotation authority. The holder of the rotation authority can then at any time without the cooperation of the custodial agent if need be revoke the agent's signing authority and assign it so some other agent or return that authority to itself. + +## Basic Fractionally Weighted Threshold + +This partial rotation feature for either reserve or custodial rotation authority is best employed with thresholds that are fractionally weighted. The exact syntax for fractionally weighted thresholds is provided later, but for the sake of explanation of partial pre-rotation, a summary is provided here. A fractionally weighted threshold consists of a list of one or more clauses where each clause is itself a list of legal rational fractions ( i.e. ratios of non-negative integers expressed as fractions, zero is not allowed in the denominator). Each entry in each clause in the fractional weight list corresponds one-to-one to a public key appearing in a key list in an establishment event. Key lists order a key set. A weight list of clauses orders a set of rational fraction weights. Satisfaction of a fractionally weighted threshold requires satisfaction of each and every clause in the list. In other words, the clauses are logically ANDed together. Satisfaction of any clause requires that the sum of the weights in that clause that correspond to verified signatures on that event must sum to at least one. Using rational fractions and rational fraction summation avoids the problem of floating-point rounding errors and ensures exactness and universality of threshold satisfaction computations. + +For example, consider the following simple single clause fractionally weighted threshold, *[1/2, 1/2, 1/2]*. Three weights mean there MUST be exactly three corresponding key pairs. Let the three keypairs in one-to-one order be denoted by the list of indexed public keys, *[A0, A1, A2]. The threshold is satisfied if any two of the public keys sign because *1/2 + 1/2 = 1*. This is exactly equivalent to an integer-valued *2 of 3* threshold. + +The order of appearance of the public key in a given key list and its associated threshold weight list MUST be the same. + +Fractionally weighted thresholds become more interesting when the weights are not all equal or include multiple clauses. Consider the following five-element single clause fractionally weighted threshold list, *[1/2, 1/2, 1/2, 1/4, 1/4]* and its corresponding public key list, *[A0, A1, A2, A3, A4]. Satisfaction would be met given signatures from any two or more of A0, A1, or A2 because each of these keys has a weight of 1/2 and the combination of any two or more sums to 1 or more. Alternatively, satisfaction would be met with signatures from any one or more of A0, A1, or A2 and both of A3, and A4 because any of those combinations would sum to 1 or more. Because participation of A3 and A4 is not required as long as at least two of A0, A1, and A2 are available then A3 and A4 may be treated as reserve members of the controlling set of keys. These reserve members only need to participate in the unfortunate event that only one of the other three is available. The flexibility of a fractionally weighted threshold enables redundancy in the combinations of keys needed to satisfice for both day-to-day and reserve contingency use cases. + +### Partial Pre-rotation Detail + +Defined herein is a detailed description of the pre-rotation protocol. This protocol includes support for *partial pre-rotation* i.e. a rotation operation on a set of pre-rotated keys that may keep some keys in reserve (i.e unexposed) while exposing others as needed. + +As described above, a valid ***rotation*** operation requires the satisfaction of two different thresholds. These are the *current* threshold of the given rotation (establishment) event with respect to its associated *current* public key list and the next threshold from the given rotation event's most recent prior establishment event with respect to its associated blinded next key digest list. For short, we denote the next threshold from the most recent prior establishment event as the *prior next* threshold, and the list of unblinded public keys taken from the blinded key digest list from the most recent prior establishment event as the *prior next* key list. Explication of the elements of the *prior next* key list requires exposing or unblinding the underlying public keys committed to by their corresponding digests that appear in the next key digest list of the most recent prior establishment event. The unexposed (blinded) public keys MAY be held in reserve. + +More precisely, any rotation event's *current* public key list MUST include a satisfiable subset of the *prior next* key list with respect to the *prior next* threshold. In addition, any rotation event's *current* public key list MUST include a satisfiable set of public keys with respect to its *current* threshold. In other words, the current public key list must be satisfiable with respect to both the *current* and *prior next* thresholds. + +To reiterate, in order to make verifiable the maintenance of the integrity of the forward commitment to the pre-rotated list of keys made by the *prior next* event, i.e. provide verifiable rotation control authority, the *current* key list MUST include a satisfiable subset of exposed (unblinded) pre-rotated next keys from the most recent prior establishment event where satisfiable is with respect to the *prior next* threshold. Moreover, in order to establish verifiable signing control authority, the *current* key list MUST also include a satisfiable subset of public keys where satisfiable is with respect to the *current* threshold. + +These two conditions are trivially satisfied whenever the *current* and *prior next* key lists and thresholds are equivalent. When both the *current* and the *prior next* key lists and thresholds are identical then the validation can be simplified by comparing the two lists and thresholds to confirm that they are identical and then confirming that the signatures satisfy the one threshold with respect to the one key list. When not identical, the validator MUST perform the appropriate set math to confirm compliance. + +Recall, that the order of appearance of the public key in a given key list and its associated threshold weight list MUST be the same. The order of appearance, however, of any public keys that appear in both the *current* and *prior next* key lists MAY be different between the two key lists and hence the two associated threshold weight lists. A validator MUST therefore confirm that the set of keys in the *current* key list truly includes a satisfiable subset of the *prior next* key list and that the *current* key list is satisfiable with respect to both the *current* and *prior next* thresholds. Actual satisfaction means that the set of attached signatures MUST satisfy both the *current* and *prior next* thresholds as applied to their respective key lists. + +Suppose that the *current* public key list does not include a proper subset of the *prior next* key list. This means that no keys were held in reserve. This also means that the current key list is either identical to the prior next key list or is a superset of the prior next key list. Nonetheless, such a rotation MAY change the *current* key list and or threshold with respect to the *prior next* key list and/or threshold as long as it meets the satisfiability constraints defined above. + +If the *current* key list includes the full set of keys from the *prior next* key list then a ***full rotation*** has occurred, not a ***partial rotation*** because no keys were held in reserve or omitted. A *full rotation* MAY add new keys to the *current* key list and/or change the current threshold with respect to the *prior next* key list and threshold. + +## Reserve Rotation Example + +Provided here is an illustrative example to help to clarify the pre-rotation protocol, especially with regard to and threshold satisfaction for reserve rotation. + +| SN | Role | Keys | Threshold | +|:-:|:-:|--:|--:| +| 0 | Crnt | *[A0, A1, A2, A3, A4]* | *[1/2, 1/2, 1/2, 1/4, 1/4]* | +| 0 | Next | *[H(A5), H(A6), H(A7), H(A8), H(A9)]* | *[1/2, 1/2, 1/2, 1/4, 1/4]* | +| 1 | Crnt | *[A5, A6, A7]* | *[1/2, 1/2, 1/2]* | +| 1 | Next | *[H(A10), H(A11), H(A12), H(A8),H(A9)]* | *[1/2, 1/2, 1/2, 1/4, 1/4]* | +| 2 | Crnt | *[A10, A8, A9]* | *[1/2, 1/2, 1/2]* | +| 2 | Next | *[H(A13), H(A14), H(A15), H(A16),H(A17)]* | *[1/2, 1/2, 1/2, 1/4, 1/4]* | +| 3 | Crnt | *[A13, A14, A15]* | *[1/2, 1/2, 1/2]* | +| 3 | Next | *[H(A18), H(A19), H(A20), H(A16),H(A17)]* | *[1/2, 1/2, 1/2, 1/4, 1/4]* | +| 4 | Crnt | *[A18, A20, A21]* | *[1/2, 1/2, 1/2]* | +| 4 | Next | *[H(A22), H(A23), H(A24), H(A16),H(A17)]* | *[1/2, 1/2, 1/2, 1/4, 1/4]* | +| 5 | Crnt | *[A22, A25, A26, A16, A17]* | *[1/2, 1/2, 1/2, 0, 0]* | +| 5 | Next | *[H(A27), H(A28), H(A29), H(A30),H(A31)]* | *[1/2, 1/2, 1/2, 1/4, 1/4]* | + +The meaning of the column labels is as follows: + ++ SN is the sequence number of the event. Each event uses two rows in the table. ++ Role is either Current (Crnt) or Next and indicates the role of the key list and threshold on that row. ++ Keys is the list of public keys denoted with indexed label of the keypair sequence. ++ Threshold is the threshold of signatures that must be satisfied for validity. + +Commentary of each event follows: + ++ (0) Inception: Five keypairs have signing authority and five other keypairs have rotation authority. Any two of the first three or any one of the first three and both the last two are sufficient. This anticipates holding the last two in reserve. + ++ (1) Rotation: The first three keypairs from the prior next, A5, A6, and A7, are rotated at the new current signing keypairs. This exposes the keypairs. The last two from the prior next, A8 and A9, are held in reserve. They have not been exposed are included in the next key list. + ++ (2) Rotation: The prior next keypairs, A11 and A12 are unavalible to sign the rotation and particpate as the part of the newly current signing keys. Therefore A8 and A9 must be activated (pulled out of reserve) and included and exposed as both one time rotation keys and newly current signing keys. The signing authority (weight) of each of A8 and A9 has been increased to 1/2 from 1/4. This means that any two of the three of A10, A8, and A9 may satisfy the signing threshold. Nonetheless, the rotation event \#2 MUST be signed by all three of A10, A8, and A9 in order to satisfy the prior next threshold because in that threshold A8, and A9 only have a weight of 1/4. + ++ (3) Rotation: The keypairs H(A16),H(A17 have been held in reserve from event \#2 + ++ (4) Rotation: The keypairs H(A16), H(A17 continue to be held in reserve. + ++ (5) Rotation: The keypairs A16, and A17 are pulled out of reserved and exposed in order to perform the rotation because A23, and A24 are unavailable. Two new keypairs, A25, A26, are added to the current signing key list. The current signing authority of A16, and A17 is none because they are assigned a weight of 0 in the new current signing threshold. For the rotation event to be valid it must be signed by A22, A16, and A17 in order to satisfy the prior next threshold for rotation authority and also must be signed by any two of A22, A25, and A26 in order to satisfy the new current signing authority for the event itself. This illustrates how reserved keypairs may be used exclusively for rotation authority and not for signing authority. + + +## Custodial Rotation Example + +Provided here is an illustrative example to help to clarify the pre-rotation protocol, especially with regard to threshold satisfaction for custodial rotation. + +| SN | Role | Keys | Threshold | +|:-:|:-:|--:|--:| +| 0 | Crnt | *[A0, A1, A2]* | *[1/2, 1/2, 1/2]* | +| 0 | Next | *[H(A3), H(A4), H(A5)]* | *[1/2, 1/2, 1/2]* | +| 1 | Crnt | *[A3, A4, A5, A6, A7, A8]* | *[0, 0, 0, 1/2, 1/2, 1/2]* | +| 1 | Next | *[H(A9), H(A10), H(A11)]* | *[1/2, 1/2, 1/2]* | +| 2 | Crnt | *[A9, A10, A11, A12, A13, A14]* | *[0, 0, 0, 1/2, 1/2, 1/2]* | +| 2 | Next | *[H(A15), H(A16), H(A17)]* | *[1/2, 1/2, 1/2]* | + +The meaning of the column labels is as follows: + ++ SN is the sequence number of the event. Each event uses two rows in the table. ++ Role is either Current (Crnt) or Next and indicates the role of the key list and threshold on that row. ++ Keys is the list of public keys denoted with indexed label of the keypair sequence. ++ Threshold is the threshold of signatures that must be satisfied for validity. + + +Commentary of each event follows: + ++ (0) Inception: The private keys from current signing keypairs A0, A1, and A2 are held by the custodian of the identifier. The owner of the identifier provides the digests of the next rotation keypairs, H(A3), H(A4), and H(A5) to the custodian in order that the custodian may include them in the event and then sign the event. The owner holds the private keys from the next rotation keypairs A3, A4, and A5. A self-addressing AID would then be created by the formulation of the inception event. Once formed, the custodian controls the signing authority over the identifier by virtue of holding the associated private keys for the current key list. But the owner controls the rotation authority by virtue of holding the associated private keys for the next key list. Because the controller of the rotation authority may at their sole discretion revoke and replace the keys that hold signing authority, the owner, holder of the next private keys, is ultimately in control of the identifier so constituted by this inception event. + ++ (1) Rotation: The owner changes custodians with this event. The new custodian creates new current signing keypairs, A6, A7, and A8 and holds the associated private keys. The new custodian provides the public keys A6, A7, and A8 to the owner so that the owner can formulate and sign the rotation event that transfers signing authority to the new custodian. The owner exposes its rotation public keys, A3, A4, and A5 by including them in the new current key list. But the weights of those rotation keys in the new current signing threshold are all 0 so they have no signing authority. The owner creates a new set of next keypairs and includes their public key digests, H(A9), H(A10), H(A11) in the new next key list. The owner holds the associated private keys and thereby retains rotation authority. This event MUST be signed by any two of A3, A4, and A5 in order to satisfy the prior next threshold and also MUST be signed by any two A6, A7, and A8 in order to satisfy the new current signing threshold. The new current threshold and new next threshold clearly delineate that the new custodian now holds exclusive signing authority and owner continues to retain exclusive rotation authority. + ++ (2) Rotation: Change to yet another custodian following the same pattern as event \#1 + + + +# KERI Data Structures + +A KERI data structure such as a key event message body may be abstractly modeled as a nested `key: value` mapping. To avoid confusion with the cryptographic use of the term *key* we instead use the term *field* to refer to a mapping pair and the terms *field label* and *field value* for each member of a pair. These pairs can be represented by two tuples e.g `(label, value)`. We qualify this terminology when necessary by using the term *field map* to reference such a mapping. *Field maps* may be nested where a given *field value* is itself a reference to another *field map*. We call this nested set of fields a *nested field map* or simply a *nested map* for short. A *field* may be represented by a framing code or block delimited serialization. In a block delimited serialization, such as JSON, each *field map* is represented by an object block with block delimiters such as `{}` {{RFC8259}}{{JSOND}}{{RFC4627}}. Given this equivalence, we may also use the term *block* or *nested block* as synonymous with *field map* or *nested field map*. In many programming languages, a field map is implemented as a dictionary or hash table in order to enable performant asynchronous lookup of a *field value* from its *field label*. Reproducible serialization of *field maps* requires a canonical ordering of those fields. One such canonical ordering is called insertion or field creation order. A list of `(field, value)` pairs provides an ordered representation of any field map. Most programming languages now support ordered dictionaries or hash tables that provide reproducible iteration over a list of ordered field `(label, value)` pairs where the ordering is the insertion or field creation order. This enables reproducible round trip serialization/deserialization of *field maps*. Serialized KERI data structures depend on insertion-ordered field maps for their canonical serialization/deserialization. KERI data structures support multiple serialization types, namely JSON, CBOR, MGPK, and CESR but for the sake of simplicity, we will only use JSON herein for examples {{RFC8259}}{{JSOND}}{{CBORC}}{{RFC8949}}{{MGPK}}{{CESR-ID}}. The basic set of normative field labels in KERI field maps is defined in the table in the following section. + +## Field Labels for KERI Data Structures + +|Label|Title|Description|Notes| +|---|---|---|---| +|v| Version String | | | +|i| Identifier Prefix (AID) | | | +|s| Sequence Number | | | +|t| Message Type | | | +|te| Last received Event Message Type in a Key State Notice | | | +|d| Event SAID || +|p| Prior Event SAID | | | +|kt| Keys Signing Threshold || | +|k| List of Signing Keys (ordered key set)| | | +|nt| Next Keys Signing Threshold || | +|n| List of Next Key Digests (ordered key digest set) | | | +|bt| Backer Threshold || | +|b| List of Backers (ordered backer set of AIDs) | | | +|br| List of Backers to Remove (ordered backer set of AIDS) | | | +|ba| List of Backers to Add (ordered backer set of AIDs) | | | +|c| List of Configuration Traits/Modes | | | +|a| List of Anchors (seals) || | +|di| Delegator Identifier Prefix (AID) | | | +|rd| Merkle Tree Root Digest (SAID) || | +|ee| Last Establishment Event Map | | | +|vn| Version Number ("major.minor") | | | + +A field label may have different values in different contexts but MUST not have a different field value ***type***. This requirement makes it easier to implement in strongly typed languages with rigid data structures. Notwithstanding the former, some field value types MAY be a union of elemental value types. + +Because the order of appearance of fields is enforced in all KERI data structures, whenever a field appears (in a given message or block in a message) the message in which a label appears MUST provide the necessary context to fully determine the meaning of that field and hence the field value type and associated semantics. + +## Compact Labels + +The primary field labels are compact in that they use only one or two characters. KERI is meant to support resource-constrained applications such as supply chain or IoT (Internet of Things) applications. Compact labels better support resource-constrained applications in general. With compact labels, the over-the-wire verifiable signed serialization consumes a minimum amount of bandwidth. Nevertheless, without loss of generality, a one-to-one normative semantic overlay using more verbose expressive field labels may be applied to the normative compact labels after verification of the over-the-wire serialization. This approach better supports bandwidth and storage constraints on transmission while not precluding any later semantic post-processing. This is a well-known design pattern for resource-constrained applications. + + +## Special Label Ordering Requirements + + + +## Version String Field + +The version string, `v`, field MUST be the first field in any top-level KERI field map in which it appears. Typically the version string, `v`, field appears as the first top-level field in a KERI message body. This enables a RegEx stream parser to consistently find the version string in any of the supported serialization formats for KERI messages. The `v` field provides a regular expression target for determining the serialization format and size (character count) of a serialized KERI message body. A stream parser may use the version string to extract and deserialize (deterministically) any serialized KERI message body in a stream of serialized KERI messages. Each KERI message in a stream may use a different serialization type. + +The format of the version string is `KERIvvSSSShhhhhh_`. The first four characters `KERI` indicate the enclosing field map serialization. The next two characters, `vv` provide the lowercase hexadecimal notation for the major and minor version numbers of the version of the KERI specification used for the serialization. The first `v` provides the major version number and the second `v` provides the minor version number. For example, `01` indicates major version 0 and minor version 1 or in dotted-decimal notation `0.1`. Likewise `1c` indicates major version 1 and minor version decimal 12 or in dotted-decimal notation `1.12`. The next four characters `SSSS` indicate the serialization type in uppercase. The four supported serialization types are `JSON`, `CBOR`, `MGPK`, and `CESR` for the JSON, CBOR, MessagePack, and CESR serialization standards respectively {{JSOND}}{{RFC4627}}{{CBORC}}{{RFC8949}}{{MGPK}}{{CESR-ID}}. The next six characters provide in lowercase hexadecimal notation the total number of characters in the serialization of the KERI message body. The maximum length of a given KERI message body is thereby constrained to be *224 = 16,777,216* characters in length. The final character `-` is the version string terminator. This enables later versions of ACDC to change the total version string size and thereby enable versioned changes to the composition of the fields in the version string while preserving deterministic regular expression extractability of the version string. Although a given KERI serialization type may use field map delimiters or framing code characters that appear before (i.e. prefix) the version string field in a serialization, the set of possible prefixes is sufficiently constrained by the allowed serialization protocols to guarantee that a regular expression can determine unambiguously the start of any ordered field map serialization that includes the version string as the first field value. Given the version string, a parser may then determine the end of the serialization so that it can extract the full serialization (KERI message body) from the stream without first deserializing it or parsing it field-by-field. This enables performant stream parsing and off-loading of KERI message streams that include any or all of the supported serialization types interleaved in a single stream. + + + + + +## SAID (Self-Addressing IDentifier) Fields + + +Some fields in KERI data structures may have for their value a SAID. In this context, `d` is short for digest, which is short for Self-Addressing IDentifier (SAID). A SAID follows the SAID protocol {{SAID-ID}}. Essentially a SAID is a Self-Addressing IDentifier (self-referential content addressable). A SAID is a special type of cryptographic digest of its encapsulating *field map* (block). The encapsulating block of a SAID is called a SAD (Self-Addressed Data). Using a SAID as a *field value* enables a more compact but secure representation of the associated block (SAD) from which the SAID is derived. Any nested field map that includes a SAID field (i.e. is, therefore, a SAD) may be compacted into its SAID. The uncompacted blocks for each associated SAID may be attached or cached to optimize bandwidth and availability without decreasing security. + +Each SAID provides a stable universal cryptographically verifiable and agile reference to its encapsulating block (serialized *field map*). + +Recall that a cryptographic commitment (such as a digital signature or cryptographic digest) on a given digest with sufficient cryptographic strength including collision resistance {{HCR}}{{QCHC}} is equivalent to a commitment to the block from which the given digest was derived. Specifically, a digital signature on a SAID makes a verifiable cryptographic non-repudiable commitment that is equivalent to a commitment on the full serialization of the associated block from which the SAID was derived. This enables reasoning about KERI data structures in whole or in part via their SAIDS in a fully interoperable, verifiable, compact, and secure manner. This also supports the well-known bow-tie model of Ricardian Contracts {{RC}}. This includes reasoning about the whole KERI data structure given by its top-level SAID, `d`, field as well as reasoning about any nested or attached data structures using their SAIDS. + + + +## AID (Autonomic IDentifier) Fields + +Some fields, such as the `i` and `di` fields, MUST each have an AID (Autonomic IDentifier) as its value. An AID is a fully qualified Self-Certifying IDentifier (SCID) as described above {{KERI}}{{KERI-ID}}. An AID MUST be self-certifying. +In this context, `i` is short for `ai`, which is short for the Autonomic IDentifier (AID). The AID given by the `i` field may also be thought of as a securely attributable identifier, authoritative identifier, authenticatable identifier, authorizing identifier, or authoring identifier.Another way of thinking about an `i` field is that it is the identifier of the authoritative entity to which a statement may be securely attributed, thereby making the statement verifiably authentic via a non-repudiable signature made by that authoritative entity as the Controller of the private key(s). + + + + +### Namespaced AIDs +Because KERI is agnostic about the namespace for any particular AID, different namespace standards may be used to express KERI AIDs within AID fields in an ACDC. The examples below use the W3C DID namespace specification with the `did:keri` method {{DIDK-ID}}. But the examples would have the same validity from a KERI perspective if some other supported namespace was used or no namespace was used at all. The latter case consists of a bare KERI AID (identifier prefix). + +ToDo Explain agnosticism vis a vis namespaces + Because AIDs may be namespaced, the essential component of an AID is the cryptographically derived Controller identifier prefix. An AID MUST be the Controller identifier prefix. part of a W3C Decentralized IDentifier (DID) {{W3C_DID}} or other namespace convention. + +Version string namespaces the AIDs as KERI so don't need any namespacing on a per identifier basis. + + +## Version String Field + +Get from ACDC + +## Next Threshold Field + +The `nt` field is next threshold for the next establishment event. + + +## Common Normalized ACDC and KERI Labels + +`v` is the version string +`d` is the SAID of the enclosing block or map +`i` is a KERI identifier AID +`a` is the data attributes or data anchors depending on the message type + + + + +# Seals + + + +## Digest Seal + +~~~json +{ + "d": "Eabcde..." +} +~~~ + +## Merkle Tree Root Digest Seal + +~~~json +{ + "rd": "Eabcde8JZAoTNZH3ULvaU6JR2nmwyYAfSVPzhzS6b5CM" +} +~~~ + +## Backer Seal + +~~~json +{ + "bi": "BACDEFG8JZAoTNZH3ULvaU6JR2nmwyYAfSVPzhzS6b5CM", + "d" : "EFGKDDA8JZAoTNZH3ULvaU6JR2nmwyYAfSVPzhzS6b5CM" +} + +~~~ + +## Event Seal +~~~json +{ + + "i": "Ebietyi8JZAoTNZH3ULvaU6JR2nmwyYAfSVPzhzS6b5CM.", + "s": "3", + "d": "Eabcde8JZAoTNZH3ULvaU6JR2nmwyYAfSVPzhzS6b5CM" +} +~~~ + + +## Last Establishment Event Seal + +~~~json +{ + "i": "BACDEFG8JZAoTNZH3ULvaU6JR2nmwyYAfSVPzhzS6b5CM", +} + +~~~ + + + +# Key Event Messages (Non-delegated) + +Because adding the `d` field SAID to every key event message type will break all the explicit test vectors. Its no additional pain to normalize the field ordering across all message types and seals. +Originally all messages included an `i` field but that is not true anymore. So the changed field ordering is to put the fields that are common to all message types first in order followed by fields that are not common. The common fields are `v`, `t`, `d`. +The newly revised messages and seals are shown below. + + + +## Inception Event + +When the AID in the `i` field is a self-addressing self-certifying AID, the new Inception Event has two +qualified digest fields. In this case both the `d` and `i` fields must have the same value. This means the digest suite's derivation code, used for the `i` field must be the same for the `d` field. +The derivation of the `d` and `i` fields is special. Both the `d` and `i` fields are replaced with dummy `#` characters of the length of the digest to be used. The digest of the Inception event is then computed and both the `d` and `i` fields are replaced with the qualified digest value. Validation of an inception event requires examining the `i` field's derivation code and if it is a digest-type then the `d` field must be identical otherwise the inception event is invalid. + +When the AID is not self-addressing, i.e. the `i` field derivation code is not a digest. Then the `i` is given its value and the `d` field is replaced with dummy characters `#` of the correct length and then the digest is computed. This is the standard SAID algorithm. + + +## Inception Event Message Body + + +~~~json +{ + "v": "KERI10JSON0001ac_", + "t": "icp", + "d": "EL1L56LyoKrIofnn0oPChS4EyzMHEEk75INJohDS_Bug", + "i": "EL1L56LyoKrIofnn0oPChS4EyzMHEEk75INJohDS_Bug", + "s": "0", + "kt": "2", // 2 of 3 + "k" : + [ + "DnmwyZ-i0H3ULvad8JZAoTNZaU6JR2YAfSVPzh5CMzS6b", + "DZaU6JR2nmwyZ-VPzhzSslkie8c8TNZaU6J6bVPzhzS6b", + "Dd8JZAoTNnmwyZ-i0H3U3ZaU6JR2LvYAfSVPzhzS6b5CM" + ], + "nt": "3", // 3 of 5 + "n" : + [ + "ETNZH3ULvYawyZ-i0d8JZU6JR2nmAoAfSVPzhzS6b5CM", + "EYAfSVPzhzaU6JR2nmoTNZH3ULvwyZb6b5CMi0d8JZAS", + "EnmwyZdi0d8JZAoTNZYAfSVPzhzaU6JR2H3ULvS6b5CM", + "ETNZH3ULvS6bYAfSVPzhzaU6JR2nmwyZfi0d8JZ5s8bk", + "EJR2nmwyZ2i0dzaU6ULvS6b5CM8JZAoTNZH3YAfSVPzh", + ], + "bt": "2", + "b": + [ + "BGKVzj4ve0VSd8z_AmvhLg4lqcC_9WYX90k03q-R_Ydo", + "BuyRFMideczFZoapylLIyCjSdhtqVb31wZkRKvPfNqkw", + "Bgoq68HCmYNUDgOz4Skvlu306o_NY-NrYuKAVhk3Zh9c" + ], + "c": [], + "a": [] +} +~~~ + + + +## Rotation Event Message Body + +~~~json +{ + "v" : "KERI10JSON00011c_", + "t" : "rot", + "d" : "E0d8JJR2nmwyYAfZAoTNZH3ULvaU6Z-iSVPzhzS6b5CM", + "i" : "EZAoTNZH3ULvaU6Z-i0d8JJR2nmwyYAfSVPzhzS6b5CM", + "s" : "1", + "p" : "EULvaU6JR2nmwyZ-i0d8JZAoTNZH3YAfSVPzhzS6b5CM", + "kt": "2", // 2 of 3 + "k" : + [ + "DnmwyZ-i0H3ULvad8JZAoTNZaU6JR2YAfSVPzh5CMzS6b", + "DZaU6JR2nmwyZ-VPzhzSslkie8c8TNZaU6J6bVPzhzS6b", + "Dd8JZAoTNnmwyZ-i0H3U3ZaU6JR2LvYAfSVPzhzS6b5CM" + ], + "nt": "3", // 3 of 5 + "n" : + [ + "ETNZH3ULvYawyZ-i0d8JZU6JR2nmAoAfSVPzhzS6b5CM", + "EYAfSVPzhzaU6JR2nmoTNZH3ULvwyZb6b5CMi0d8JZAS", + "EnmwyZdi0d8JZAoTNZYAfSVPzhzaU6JR2H3ULvS6b5CM", + "ETNZH3ULvS6bYAfSVPzhzaU6JR2nmwyZfi0d8JZ5s8bk", + "EJR2nmwyZ2i0dzaU6ULvS6b5CM8JZAoTNZH3YAfSVPzh", + ], + "bt": "1", + "ba": ["DTNZH3ULvaU6JR2nmwyYAfSVPzhzS6bZ-i0d8JZAo5CM"], + "br": ["DH3ULvaU6JR2nmwyYAfSVPzhzS6bZ-i0d8TNZJZAo5CM"], + "a" : [] +} +~~~ + + +## Interaction Event Message Body + +~~~json +{ + "v": "KERI10JSON00011c_", + "t": "isn", + "d": "E0d8JJR2nmwyYAfZAoTNZH3ULvaU6Z-iSVPzhzS6b5CM", + "i": "EZAoTNZH3ULvaU6Z-i0d8JJR2nmwyYAfSVPzhzS6b5CM", + "s": "2", + "p": "EULvaU6JR2nmwyZ-i0d8JZAoTNZH3YAfSVPzhzS6b5CM", + "a": + [ + { + "d": "ELvaU6Z-i0d8JJR2nmwyYAZAoTNZH3UfSVPzhzS6b5CM", + "i": "EJJR2nmwyYAfSVPzhzS6b5CMZAoTNZH3ULvaU6Z-i0d8", + "s": "1" + } + ] +} +~~~ + + +# Delegated Key Event Messages + + +ToDo in delegation section below. Delegated custodial example with partial rotation and using 0 fraction signing weights on exposed pre-rotated keys + + + +## Delegated Inception Event Message Body + +~~~json +{ + "v": "KERI10JSON0001ac_", + "t": "icp", + "d": "EL1L56LyoKrIofnn0oPChS4EyzMHEEk75INJohDS_Bug", + "i": "EL1L56LyoKrIofnn0oPChS4EyzMHEEk75INJohDS_Bug", + "s": "0", + "kt": "2", // 2 of 3 + "k" : + [ + "DnmwyZ-i0H3ULvad8JZAoTNZaU6JR2YAfSVPzh5CMzS6b", + "DZaU6JR2nmwyZ-VPzhzSslkie8c8TNZaU6J6bVPzhzS6b", + "Dd8JZAoTNnmwyZ-i0H3U3ZaU6JR2LvYAfSVPzhzS6b5CM" + ], + "nt": "3", // 3 of 5 + "n" : + [ + "ETNZH3ULvYawyZ-i0d8JZU6JR2nmAoAfSVPzhzS6b5CM", + "EYAfSVPzhzaU6JR2nmoTNZH3ULvwyZb6b5CMi0d8JZAS", + "EnmwyZdi0d8JZAoTNZYAfSVPzhzaU6JR2H3ULvS6b5CM", + "ETNZH3ULvS6bYAfSVPzhzaU6JR2nmwyZfi0d8JZ5s8bk", + "EJR2nmwyZ2i0dzaU6ULvS6b5CM8JZAoTNZH3YAfSVPzh", + ], + "bt": "2", + "b": + [ + "BGKVzj4ve0VSd8z_AmvhLg4lqcC_9WYX90k03q-R_Ydo", + "BuyRFMideczFZoapylLIyCjSdhtqVb31wZkRKvPfNqkw", + "Bgoq68HCmYNUDgOz4Skvlu306o_NY-NrYuKAVhk3Zh9c" + ], + "c": [], + "a": [], + "di": "EJJR2nmwyYAZAoTNZH3ULvaU6Z-i0d8fSVPzhzS6b5CM" +} +~~~ + + + + +## Delegated Rotation Event Message Body + +~~~json +{ + "v" : "KERI10JSON00011c_", + "t" : "drt", + "d" : "E0d8JJR2nmwyYAfZAoTNZH3ULvaU6Z-iSVPzhzS6b5CM", + "i" : "EZAoTNZH3ULvaU6Z-i0d8JJR2nmwyYAfSVPzhzS6b5CM", + "s" : "1", + "p" : "EULvaU6JR2nmwyZ-i0d8JZAoTNZH3YAfSVPzhzS6b5CM", + "kt": "2", // 2 of 3 + "k" : + [ + "DnmwyZ-i0H3ULvad8JZAoTNZaU6JR2YAfSVPzh5CMzS6b", + "DZaU6JR2nmwyZ-VPzhzSslkie8c8TNZaU6J6bVPzhzS6b", + "Dd8JZAoTNnmwyZ-i0H3U3ZaU6JR2LvYAfSVPzhzS6b5CM" + ], + "nt": "3", // 3 of 5 + "n" : + [ + "ETNZH3ULvYawyZ-i0d8JZU6JR2nmAoAfSVPzhzS6b5CM", + "EYAfSVPzhzaU6JR2nmoTNZH3ULvwyZb6b5CMi0d8JZAS", + "EnmwyZdi0d8JZAoTNZYAfSVPzhzaU6JR2H3ULvS6b5CM", + "ETNZH3ULvS6bYAfSVPzhzaU6JR2nmwyZfi0d8JZ5s8bk", + "EJR2nmwyZ2i0dzaU6ULvS6b5CM8JZAoTNZH3YAfSVPzh", + ], + "bt": "1", + "ba": ["DTNZH3ULvaU6JR2nmwyYAfSVPzhzS6bZ-i0d8JZAo5CM"], + "br": ["DH3ULvaU6JR2nmwyYAfSVPzhzS6bZ-i0d8TNZJZAo5CM"], + "a" :[] + "di" : "EJJR2nmwyYAZAoTNZH3ULvaU6Z-i0d8fSVPzhzS6b5CM" +} +~~~ + + +# Receipt Messages + +## Non-Transferable Prefix Signer Receipt Message Body +For receipts, the `d` field is the SAID of the associated event, not the receipt message itself. + + +~~~json +{ + "v": "KERI10JSON00011c_", + "t": "rct", + "d": "DZ-i0d8JZAoTNZH3ULvaU6JR2nmwyYAfSVPzhzS6b5CM", + "i": "AaU6JR2nmwyZ-i0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM", + "s": "1" +} +~~~ + +## Transferable Prefix Signer Receipt Message Body +For receipts, the `d` field is the SAID of the associated event, not the receipt message itself. + +~~~json +{ + "v": "KERI10JSON00011c_", + "t": "vrc", + "d": "DZ-i0d8JZAoTNZH3ULvaU6JR2nmwyYAfSVPzhzS6b5CM", + "i": "AaU6JR2nmwyZ-i0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM", + "s": "1", + "a": + { + "d": "DZ-i0d8JZAoTNZH3ULvaU6JR2nmwyYAfSVPzhzS6b5CM", + "i": "AYAfSVPzhzS6b5CMaU6JR2nmwyZ-i0d8JZAoTNZH3ULv", + "s": "4" + } +} +~~~ + + +# Other Messages + +## Query Message Message Body + +~~~json +{ + "v" : "KERI10JSON00011c_", + "t" : "qry", + "d" : "EZ-i0d8JZAoTNZH3ULaU6JR2nmwyvYAfSVPzhzS6b5CM", + "dt": "2020-08-22T17:50:12.988921+00:00", + "r" : "logs", + "rr": "log/processor", + "q" : + { + "i" : "EaU6JR2nmwyZ-i0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM", + "s" : "5", + "dt": "2020-08-01T12:20:05.123456+00:00", + } +} +~~~ + +~~~json +{ + "v" : "KERI10JSON00011c_", + "t" : "qry", + "d" : "EZ-i0d8JZAoTNZH3ULaU6JR2nmwyvYAfSVPzhzS6b5CM", + "dt": "2020-08-22T17:50:12.988921+00:00", + "r" : "logs", + "rr": "log/processor", + "q" : + { + "d" : "EaU6JR2nmwyZ-i0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM", + "i" : "EAoTNZH3ULvYAfSVPzhzS6baU6JR2nmwyZ-i0d8JZ5CM", + "s" : "5", + "dt": "2020-08-01T12:20:05.123456+00:00", + } +} +~~~ + +## Reply Message Body + +~~~json +{ + "v" : "KERI10JSON00011c_", + "t" : "rpy", + "d" : "EZ-i0d8JZAoTNZH3ULaU6JR2nmwyvYAfSVPzhzS6b5CM", + "dt": "2020-08-22T17:50:12.988921+00:00", + "r" : "logs/processor", + "a" : + { + "i": "EAoTNZH3ULvYAfSVPzhzS6baU6JR2nmwyZ-i0d8JZ5CM", + "name": "John Jones", + "role": "Founder", + } +} +~~~ + +~~~json +{ + "v" : "KERI10JSON00011c_", + "t" : "rpy", + "d" : "EZ-i0d8JZAoTNZH3ULaU6JR2nmwyvYAfSVPzhzS6b5CM", + "dt": "2020-08-22T17:50:12.988921+00:00", + "r" : "logs/processor", + "a" : + { + "d": "EaU6JR2nmwyZ-i0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM", + "i": "EAoTNZH3ULvYAfSVPzhzS6baU6JR2nmwyZ-i0d8JZ5CM", + "name": "John Jones", + "role": "Founder", + } +} +~~~ + +## Prod Message Body + +~~~json +{ + "v": "KERI10JSON00011c_", + "t": "prd", + "d": "EZ-i0d8JZAoTNZH3ULaU6JR2nmwyvYAfSVPzhzS6b5CM", + "r": "sealed/data", + "rr": "process/sealed/data" + "q": + { + d" : "EaU6JR2nmwyZ-i0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM", + "i" : "EAoTNZH3ULvYAfSVPzhzS6baU6JR2nmwyZ-i0d8JZ5CM", + "s" : "5", + "ri": "EAoTNZH3ULvYAfSVPzhzS6baU6JR2nmwyZ-i0d8JZ5CM", + "dd": "EaU6JR2nmwyZ-i0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM" + } +} +~~~ + + +## Bare Message Body + +Reference to the anchoring seal is provided as an attachment to the bare, `bre` message. +A bare, 'bre', message is a SAD item with an associated derived SAID in its 'd' field. + +~~~json +{ + "v": "KERI10JSON00011c_", + "t": "bre", + "d": "EZ-i0d8JZAoTNZH3ULaU6JR2nmwyvYAfSVPzhzS6b5CM", + "r": "process/sealed/data", + "a": + { + "d": "EaU6JR2nmwyZ-i0d8JZAoTNZH3ULvYAfSVPzhzS6b5CM", + "i": "EAoTNZH3ULvYAfSVPzhzS6baU6JR2nmwyZ-i0d8JZ5CM", + "dt": "2020-08-22T17:50:12.988921+00:00", + "name": "John Jones", + "role": "Founder", + } +} +~~~ + + +## Exchange Message Body + +~~~json +{ + "v": "KERI10JSON00006a_", + "t": "exn", + "d": "EF3Dd96ATbbMIZgUBBwuFAWx3_8s5XSt_0jeyCRXq_bM", + "dt": "2021-11-12T19:11:19.342132+00:00", + "r": "/echo", + "rr": "/echo/response", + "a": { + "msg": "test" + } +} +~~~ + +# Notices Embedded in Reply Messages + +## Key State Notice (KSN) + +~~~json +{ + "v": "KERI10JSON0001d9_", + "d": "EYk4PigtRsCd5W2so98c8r8aeRHoixJK7ntv9mTrZPmM", + "i": "E4BsxCYUtUx3d6UkDVIQ9Ke3CLQfqWBfICSmjIzkS1u4", + "s": "0", + "p": "", + "f": "0", + "dt": "2021-01-01T00:00:00.000000+00:00", + "et": "icp", + "kt": "1", + "k": [ + "DqI2cOZ06RwGNwCovYUWExmdKU983IasmUKMmZflvWdQ" + ], + "n": "E7FuL3Z_KBgt_QAwuZi1lUFNC69wvyHSxnMFUsKjZHss", + "bt": "1", + "b": [ + "BFUOWBaJz-sB_6b-_u_P9W8hgBQ8Su9mAtN9cY2sVGiY" + ], + "c": [], + "ee": { + "s": "0", + "d": "EYk4PigtRsCd5W2so98c8r8aeRHoixJK7ntv9mTrZPmM", + "br": [], + "ba": [] + }, + "di": "" +} +~~~ + +## Embedded in Reply + +~~~json +{ + "v" : "KERI10JSON00011c_", + "t" : "rpy", + "d" : "EZ-i0d8JZAoTNZH3ULaU6JR2nmwyvYAfSVPzhzS6b5CM", + "dt": "2020-08-22T17:50:12.988921+00:00", + "r" : "/ksn/BFUOWBaJz-sB_6b-_u_P9W8hgBQ8Su9mAtN9cY2sVGiY", + "a" : + { + "v": "KERI10JSON0001d9_", + "d": "EYk4PigtRsCd5W2so98c8r8aeRHoixJK7ntv9mTrZPmM", + "i": "E4BsxCYUtUx3d6UkDVIQ9Ke3CLQfqWBfICSmjIzkS1u4", + "s": "0", + "p": "", + "f": "0", + "dt": "2021-01-01T00:00:00.000000+00:00", + "et": "icp", + "kt": "1", + "k": [ + "DqI2cOZ06RwGNwCovYUWExmdKU983IasmUKMmZflvWdQ" + ], + "n": "E7FuL3Z_KBgt_QAwuZi1lUFNC69wvyHSxnMFUsKjZHss", + "bt": "1", + "b": [ + "BFUOWBaJz-sB_6b-_u_P9W8hgBQ8Su9mAtN9cY2sVGiY" + ], + "c": [], + "ee": { + "s": "0", + "d": "EYk4PigtRsCd5W2so98c8r8aeRHoixJK7ntv9mTrZPmM", + "br": [], + "ba": [] + }, + "di": "" + } +} +~~~ + +## Transaction State Notice (TSN) + +~~~json +{ + "v": "KERI10JSON0001b0_", + "d": "EpltHxeKueSR1a7e0_oSAhgO6U7VDnX7x4KqNCwBqbI0", + "i": "EoN_Ln_JpgqsIys-jDOH8oWdxgWqs7hzkDGeLWHb9vSY", + "s": "1", + "ii": "EaKJ0FoLxO1TYmyuprguKO7kJ7Hbn0m0Wuk5aMtSrMtY", + "dt": "2021-01-01T00:00:00.000000+00:00", + "et": "vrt", + "a": { + "s": 2, + "d": "Ef12IRHtb_gVo5ClaHHNV90b43adA0f8vRs3jeU-AstY" + }, + "bt": "1", + "br": [], + "ba": [ + "BwFbQvUaS4EirvZVPUav7R_KDHB8AKmSfXNpWnZU_YEU" + ], + "b": [ + "BwFbQvUaS4EirvZVPUav7R_KDHB8AKmSfXNpWnZU_YEU" + ], + "c": [] +} +~~~ + +## Embedded in Reply + +~~~json +{ + "v" : "KERI10JSON00011c_", + "t" : "rpy", + "d" : "EZ-i0d8JZAoTNZH3ULaU6JR2nmwyvYAfSVPzhzS6b5CM", + "dt": "2020-08-22T17:50:12.988921+00:00", + "r" : "/ksn/registry/BwFbQvUaS4EirvZVPUav7R_KDHB8AKmSfXNpWnZU_YEU", + "a" : + { + "v": "KERI10JSON0001b0_", + "d": "EpltHxeKueSR1a7e0_oSAhgO6U7VDnX7x4KqNCwBqbI0", + "i": "EoN_Ln_JpgqsIys-jDOH8oWdxgWqs7hzkDGeLWHb9vSY", + "s": "1", + "ii": "EaKJ0FoLxO1TYmyuprguKO7kJ7Hbn0m0Wuk5aMtSrMtY", + "dt": "2021-01-01T00:00:00.000000+00:00", + "et": "vrt", + "a": { + "s": 2, + "d": "Ef12IRHtb_gVo5ClaHHNV90b43adA0f8vRs3jeU-AstY" + }, + "bt": "1", + "br": [], + "ba": [ + "BwFbQvUaS4EirvZVPUav7R_KDHB8AKmSfXNpWnZU_YEU" + ], + "b": [ + "BwFbQvUaS4EirvZVPUav7R_KDHB8AKmSfXNpWnZU_YEU" + ], + "c": [] + } +} +~~~ + +# Transaction Event Log Messages + +## Registry Inception Event Message Body + +~~~json +{ + "v" : "KERI10JSON00011c_", + "t" : "vcp", + "d" : "ELh3eYC2W_Su1izlvm0xxw01n3XK8bdV2Zb09IqlXB7A", + "i" : "ELh3eYC2W_Su1izlvm0xxw01n3XK8bdV2Zb09IqlXB7A", + "ii": "EJJR2nmwyYAfSVPzhzS6b5CMZAoTNZH3ULvaU6Z-i0d8", + "s" : "0", + "bt": "1", + "b" : ["BbIg_3-11d3PYxSInLN-Q9_T2axD6kkXd3XRgbGZTm6s"], + "c" : ["NB"] +} + +~~~ + +## Registry Rotation Event Message Body + +~~~json +{ + "v" : "KERI10JSON00011c_", + "t" : "vrt", + "d" : "ELh3eYC2W_Su1izlvm0xxw01n3XK8bdV2Zb09IqlXB7A", + "i" : "E_D0eYC2W_Su1izlvm0xxw01n3XK8bdV2Zb09IqA7BxL", + "s" : "2", + "p" : "ELh3eYC2W_Su1izlvm0xxw01n3XK8bdV2Zb09IqlXB7A", + "bt": "1", + "br" : ["BbIg_3-11d3PYxSInLN-Q9_T2axD6kkXd3XRgbGZTm6s"], + "ba" : [] +} +~~~ + +## Backerless ACDC Issuance Message Body + +~~~json +{ + "v" : "KERI10JSON00011c_", + "t" : "iss", + "d" : "ELh3eYC2W_Su1izlvm0xxw01n3XK8bdV2Zb09IqlXB7A", + "i" : "E_D0eYC2W_Su1izlvm0xxw01n3XK8bdV2Zb09IqA7BxL", + "s" : "0", + "ri" : "ELh3eYC2W_Su1izlvm0xxw01n3XK8bdV2Zb09IqlXB7A", + "dt": "2020-08-01T12:20:05.123456+00:00" +} +~~~ + +## Backerless ACDC Revocation Message Body + +~~~json +{ + "v" : "KERI10JSON00011c_", + "t" : "rev", + "d" : "ELh3eYC2W_Su1izlvm0xxw01n3XK8bdV2Zb09IqlXB7A", + "i" : "E_D0eYC2W_Su1izlvm0xxw01n3XK8bdV2Zb09IqA7BxL", + "s" : "1", + "p" : "ELh3eYC2W_Su1izlvm0xxw01n3XK8bdV2Zb09IqlXB7A", + "ri" : "ELh3eYC2W_Su1izlvm0xxw01n3XK8bdV2Zb09IqlXB7A", + "dt": "2020-08-01T12:20:05.123456+00:00" +} +~~~ + +## Backered ACDC Issuance Message Body + +~~~json +{ + "v" : "KERI10JSON00011c_", + "t" : "bis", + "d" : "ELh3eYC2W_Su1izlvm0xxw01n3XK8bdV2Zb09IqlXB7A", + "i" : "E_D0eYC2W_Su1izlvm0xxw01n3XK8bdV2Zb09IqA7BxL", + "s" : "0", + "ri" : "ELh3eYC2W_Su1izlvm0xxw01n3XK8bdV2Zb09IqlXB7A", + "ra" : { + "d": "E8ipype17kJlQfYp3gcF3F1PNKfdX6vpOLXU8YyykB5o", + "i": "EFvQCx4-O9bb9fGzY7KgbPeUtjtU0M4OBQWsiIk8za24", + "s": 0 + } + "dt": "2020-08-01T12:20:05.123456+00:00" +} +~~~ + +### Backered ACDC Revocation Message Body + +~~~json +{ + "v" : "KERI10JSON00011c_", + "t" : "brv", + "d" : "ELh3eYC2W_Su1izlvm0xxw01n3XK8bdV2Zb09IqlXB7A", + "i" : "E_D0eYC2W_Su1izlvm0xxw01n3XK8bdV2Zb09IqA7BxL", + "s" : "1", + "p" : "ELh3eYC2W_Su1izlvm0xxw01n3XK8bdV2Zb09IqlXB7A", + "ri" : "EvxMACzQxU2rDj-X5SPDZYtUn56i4fjjH8yDRFRzaMfI", + "ra" : { + "d": "E8ipype17kJlQfYp3gcF3F1PNKfdX6vpOLXU8YyykB5o", + "i": "EFvQCx4-O9bb9fGzY7KgbPeUtjtU0M4OBQWsiIk8za24", + "s": 0 + } + "dt": "2020-08-01T12:20:05.123456+00:00" +} +~~~ + + + + +# Appendix: Cryptographic Strength and Security + +## Cryptographic Strength + +For crypto-systems with *perfect-security*, the critical design parameter is the number of bits of entropy needed to resist any practical brute force attack. In other words, when a large random or pseudo-random number from a cryptographic strength pseudo-random number generator (CSPRNG) {{CSPRNG}} expressed as a string of characters is used as a seed or private key to a cryptosystem with *perfect-security*, the critical design parameter is determined by the amount of random entropy in that string needed to withstand a brute force attack. Any subsequent cryptographic operations must preserve that minimum level of cryptographic strength. In information theory {{IThry}}{{ITPS}} the entropy of a message or string of characters is measured in bits. Another way of saying this is that the degree of randomness of a string of characters can be measured by the number of bits of entropy in that string. Assuming conventional non-quantum computers, the convention wisdom is that, for systems with information-theoretic or perfect security, the seed/key needs to have on the order of 128 bits (16 bytes, 32 hex characters) of entropy to practically withstand any brute force attack {{TMCrypto}}{{QCHC}}. A cryptographic quality random or pseudo-random number expressed as a string of characters will have essentially as many bits of entropy as the number of bits in the number. For other crypto-systems such as digital signatures that do not have perfect security, the size of the seed/key may need to be much larger than 128 bits in order to maintain 128 bits of cryptographic strength. + +An N-bit long base-2 random number has 2N different possible values. Given that no other information is available to an attacker with perfect security, the attacker may need to try every possible value before finding the correct one. Thus the number of attempts that the attacker would have to try maybe as much as 2N-1. Given available computing power, one can easily show that 128 is a large enough N to make brute force attack computationally infeasible. + +Let's suppose that the adversary has access to supercomputers. Current supercomputers can perform on the order of one quadrillion operations per second. Individual CPU cores can only perform about 4 billion operations per second, but a supercomputer will parallelly employ many cores. A quadrillion is approximately 250 = 1,125,899,906,842,624. Suppose somehow an adversary had control over one million (220 = 1,048,576) supercomputers which could be employed in parallel when mounting a brute force attack. The adversary could then try 250 * 220 = 270 values per second (assuming very conservatively that each try only took one operation). +There are about 3600 * 24 * 365 = 313,536,000 = 2log2313536000=224.91 ~= 225 seconds in a year. Thus this set of a million super computers could try 250+20+25 = 295 values per year. For a 128-bit random number this means that the adversary would need on the order of 2128-95 = 233 = 8,589,934,592 years to find the right value. This assumes that the value of breaking the cryptosystem is worth the expense of that much computing power. Consequently, a cryptosystem with perfect security and 128 bits of cryptographic strength is computationally infeasible to break via brute force attack. + +## Information Theoretic Security and Perfect Security + +The highest level of cryptographic security with respect to a cryptographic secret (seed, salt, or private key) is called *information-theoretic security* {{ITPS}}. A cryptosystem that has this level of security cannot be broken algorithmically even if the adversary has nearly unlimited computing power including quantum computing. It must be broken by brute force if at all. Brute force means that in order to guarantee success the adversary must search for every combination of key or seed. A special case of *information-theoretic security* is called *perfect-security* {{ITPS}}. *Perfect-security* means that the ciphertext provides no information about the key. There are two well-known cryptosystems that exhibit *perfect security*. The first is a *one-time-pad* (OTP) or Vernum Cipher {{OTP}}{{VCphr}}, the other is *secret splitting* {{SSplt}}, a type of secret sharing {{SShr}} that uses the same technique as a *one-time-pad*. + + + + + +# Conventions and Definitions + +{::boilerplate bcp14-tagged} + + +# Security Considerations + +TODO Security + + +# IANA Considerations + +This document has no IANA actions. + + +--- back + +# Acknowledgments +{:numbered="false"} + +KERI Community at the WebOfTrust Github project. diff --git a/docs/specs/references/oid4vp-1.0.txt b/docs/specs/references/oid4vp-1.0.txt new file mode 100644 index 0000000..f639df9 --- /dev/null +++ b/docs/specs/references/oid4vp-1.0.txt @@ -0,0 +1,3834 @@ + + +OpenID for Verifiable Presentations 1.0 + +openid-4-vp +July 2025 + +Terbu, et al. +Standards Track +[Page] + +Workgroup: +OpenID Digital Credentials Protocols +Published: + +9 July 2025 + +Status: +Final +Authors: + + O. Terbu + +MATTR + + T. Lodderstedt + +SPRIND + + K. Yasuda + +SPRIND + + D. Fett + +Authlete + + J. Heenan + +Authlete + +OpenID for Verifiable Presentations 1.0 + + Abstract + +This specification defines a protocol for requesting and presenting Credentials.¶ + + ▲ +Table of Contents + + 1.  Introduction + + 1.1.  Additional Authors + + 1.2.  Errata revisions + + 1.3.  Requirements Notation and Conventions + + 2.  Terminology + + 3.  Overview + + 3.1.  Same Device Flow + + 3.2.  Cross Device Flow + + 4.  Scope + + 5.  Authorization Request + + 5.1.  New Parameters + + 5.2.  Existing Parameters + + 5.3.  Requesting Presentations without Holder Binding Proofs + + 5.4.  Examples + + 5.5.  Using scope Parameter to Request Presentations + + 5.6.  Response Type vp_token + + 5.7.  Passing Authorization Request Across Devices + + 5.8.  aud of a Request Object + + 5.9.  Client Identifier Prefix and Verifier Metadata Management + + 5.9.1.  Syntax + + 5.9.2.  Fallback + + 5.9.3.  Defined Client Identifier Prefixes + + 5.10. Request URI Method post + + 5.10.1.  Request URI Response + + 5.10.2.  Request URI Error Response + + 5.11. Verifier Info + + 5.11.1.  Proof of Possession + + 6.  Digital Credentials Query Language (DCQL) + + 6.1.  Credential Query + + 6.1.1.  Trusted Authorities Query + + 6.2.  Credential Set Query + + 6.3.  Claims Query + + 6.4.  Selecting Claims and Credentials + + 6.4.1.  Selecting Claims + + 6.4.2.  Selecting Credentials + + 6.4.3.  User Interface Considerations + + 7.  Claims Path Pointer + + 7.1.  Semantics for JSON-based credentials + + 7.1.1.  Processing + + 7.2.  Semantics for ISO mdoc-based credentials + + 7.2.1.  Processing + + 7.3.  Claims Path Pointer Example + + 7.4.  DCQL Examples + + 8.  Response + + 8.1.  Response Parameters + + 8.1.1.  Examples + + 8.2.  Response Mode "direct_post" + + 8.3.  Encrypted Responses + + 8.3.1.  Response Mode "direct_post.jwt" + + 8.4.  Transaction Data + + 8.5.  Error Response + + 8.6.  VP Token Validation + + 9.  Wallet Invocation + + 10. Wallet Metadata (Authorization Server Metadata) + + 10.1.  Additional Wallet Metadata Parameters + + 10.2.  Obtaining Wallet's Metadata + + 11. Verifier Metadata (Client Metadata) + + 11.1.  Additional Verifier Metadata Parameters + + 12. Verifier Attestation JWT + + 13. Implementation Considerations + + 13.1.  Static Configuration Values of the Wallets + + 13.1.1.  Profiles that Define Static Configuration Values + + 13.1.2.  A Set of Static Configuration Values bound to openid4vp:// + + 13.2.  Nested Presentations + + 13.3.  Response Mode direct_post + + 13.4.  Pre-Final Specifications + + 14. Security Considerations + + 14.1.  Preventing Replay of Verifiable Presentations + + 14.1.1.  Presentations without Holder Binding Proofs + + 14.1.2.  Verifiable Presentations + + 14.2.  Session Fixation + + 14.3.  Response Mode "direct_post" + + 14.3.1.  Validation of the Response URI + + 14.3.2.  Protection of the Response URI + + 14.3.3.  Protection of the Authorization Response Data + + 14.4.  End-User Authentication using Credentials + + 14.5.  Encrypting an Unsigned Response + + 14.6.  TLS Requirements + + 14.7.  Incomplete or Incorrect Implementations of the Specifications and Conformance Testing + + 14.8.  Always Use the Full Client Identifier + + 14.9.  Security Checks on the Returned Credentials and Presentations + + 15. Privacy Considerations + + 15.1.  User Consent + + 15.2.  Privacy Notice + + 15.3.  Purpose Legitimacy + + 15.4.  Selective Disclosure + + 15.4.1.  DCQL Value Matching + + 15.4.2.  Strictly Necessary Claims + + 15.5.  Verifier-to-Verifier Unlinkable Presentations + + 15.6.  No Fingerprinting of the End-User + + 15.7.  Information Security + + 15.8.  Wallet to Verifier Communication + + 15.8.1.  Establishing Trust in the Request URI + + 15.8.2.  Authorization Requests with Request URI + + 15.9.  Error Responses + + 15.9.1.  wallet_unavailable Authorization Error Response + + 15.9.2.  Digital Credential API Error Responses + + 15.10. Establishing Trust in the Issuers + + 16. Normative References + + 17. Informative References + + Appendix A.  OpenID4VP over the Digital Credentials API + + A.1.  Protocol + + A.2.  Request + + A.3.  Signed and Unsigned Requests + + A.3.1.  Unsigned Request + + A.3.2.  Signed Request + + A.4.  Response + + A.5.  Security Considerations + + A.6.  Privacy Considerations + + Appendix B.  Credential Format Specific Parameters and Rules + + B.1.  W3C Verifiable Credentials + + B.1.1.  Parameters in the meta parameter in Credential Query + + B.1.2.  Claims Matching + + B.1.3.  Formats and Examples + + B.2.  Mobile Documents or mdocs (ISO/IEC 18013 and ISO/IEC 23220 series) + + B.2.1.  Transaction Data + + B.2.2.  Metadata + + B.2.3.  Parameter in the meta parameter in Credential Query + + B.2.4.  Parameter in the Claims Query + + B.2.5.  Presentation Response + + B.2.6.  Handover and SessionTranscript Definitions + + B.3.  IETF SD-JWT VC + + B.3.1.  Format Identifier + + B.3.2.  Example Credential + + B.3.3.  Transaction Data + + B.3.4.  Metadata + + B.3.5.  Parameter in the meta parameter in Credential Query + + B.3.6.  Presentation Response + + B.3.7.  SD-JWT VCLD + + Appendix C.  Combining this specification with SIOPv2 + + C.1.  Request + + C.2.  Response + + Appendix D.  Examples for DCQL Queries + + Appendix E.  IANA Considerations + + E.1.  OAuth Authorization Endpoint Response Types Registry + + E.1.1.  vp_token + + E.1.2.  vp_token id_token + + E.2.  OAuth Parameters Registry + + E.2.1.  dcql_query + + E.2.2.  client_metadata + + E.2.3.  request_uri_method + + E.2.4.  transaction_data + + E.2.5.  wallet_nonce + + E.2.6.  response_uri + + E.2.7.  vp_token + + E.2.8.  verifier_info + + E.2.9.  expected_origins + + E.3.  OAuth Extensions Error Registry + + E.3.1.  vp_formats_not_supported + + E.3.2.  invalid_request_uri_method + + E.3.3.  wallet_unavailable + + E.4.  OAuth Authorization Server Metadata Registry + + E.4.1.  vp_formats_supported + + E.5.  OAuth Dynamic Client Registration Metadata Registry + + E.5.1.  encrypted_response_enc_values_supported + + E.5.2.  vp_formats_supported + + E.6.  Media Types Registry + + E.6.1.  application/verifier-attestation+jwt + + E.7.  JSON Web Signature and Encryption Header Parameters Registry + + E.7.1.  jwt + + E.7.2.  client_id + + E.8.  Uniform Resource Identifier (URI) Schemes Registry + + E.8.1.  openid4vp + + E.9.  JSON Web Token Claims Registration + + Appendix F.  Acknowledgements + + Appendix G.  Notices + + Authors' Addresses + +1. Introduction + +This specification defines a mechanism on top of OAuth 2.0 [RFC6749] for requesting and delivering Presentations of Credentials. Credentials and Presentations can be of any format, including, but not limited to W3C Verifiable Credentials Data Model [VC_DATA], ISO mdoc [ISO.18013-5], and IETF SD-JWT VC [I-D.ietf-oauth-sd-jwt-vc].¶ + +OAuth 2.0 [RFC6749] is used as a base protocol as it provides the required rails to build a simple, secure, and developer-friendly Credential presentation layer on top of it. Moreover, implementers can, in a single interface, support Credential presentation and the issuance of Access Tokens for access to APIs based on Credentials in the Wallet. OpenID Connect [OpenID.Core] deployments can also extend their implementations using this specification with the ability to transport Credential Presentations.¶ + +This specification can also be combined with [SIOPv2], if implementers require OpenID Connect features, such as the issuance of Self-Issued ID Tokens [SIOPv2].¶ + +Additionally, it defines how to use OpenID4VP in conjunction with the Digital Credentials API (DC API) [W3C.Digital_Credentials_API]. See section Appendix A for all requirements applicable to implementers of OpenID4VP over the DC API. Except where it explicitly references other sections of this specification, that section is self-contained, and its implementers can ignore the rest of the specification.¶ + +1.1. Additional Authors + +Tobias Looker (MATTR)¶ + + Adam Lemmon (MATTR)¶ + +1.2. Errata revisions + +The latest revision of this specification, incorporating any errata updates, is published at openid-4-verifiable-presentations-1_0. The text of the final specification as approved will always be available at openid-4-verifiable-presentations-1_0-final. When referring to this specification from other documents, it is recommended to reference openid-4-verifiable-presentations-1_0.¶ + +1.3. Requirements Notation and Conventions + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all capitals, as shown here.¶ + +2. Terminology + +This specification uses the terms "Access Token", "Authorization Request", "Authorization Response", "Client", "Client Authentication", "Client Identifier", "Grant Type", "Response Type", "Token Request" and "Token Response" defined by OAuth 2.0 [RFC6749], the terms "End-User" and "Entity" as defined by OpenID Connect Core [OpenID.Core], the terms "Request Object" and "Request URI" as defined by [RFC9101], the term "JSON Web Token (JWT)" defined by JSON Web Token (JWT) [RFC7519], the term "JOSE Header" defined by JSON Web Signature (JWS) [RFC7515], the term "JSON Web Encryption (JWE)" defined by [RFC7516], and the term "Response Mode" defined by OAuth 2.0 Multiple Response Type Encoding Practices [OAuth.Responses].¶ + +Base64url-encoded denotes the URL-safe base64 encoding without padding defined in Section 2 of [RFC7515].¶ + +This specification also defines the following terms. In the case where a term has a definition that differs, the definition below is authoritative.¶ + + Biometrics-based Holder Binding: + Ability of the Holder to prove legitimate possession of a Credential by demonstrating a certain biometric trait, such as a fingerprint or face. One example of a Credential with biometric Holder Binding is a mobile driving license [ISO.18013-5], which contains a portrait of the Holder.¶ + +Claims-based Holder Binding: + Ability of the Holder to prove legitimate possession of a Credential by proving certain claims, e.g., name and date of birth, for example by presenting another Credential. Claims-based Holder Binding allows long-term, cross-device use of a Credential as it does not depend on cryptographic key material stored on a certain device. One example of such a Credential could be a diploma.¶ + +Credential: + A set of one or more claims about a subject made by a Credential Issuer. In this specification, Credentials are usually Verifiable Credentials (defined below). Note that the definition of the term "Credential" in this specification is different from that in [OpenID.Core].¶ + +Credential Format Identifier: + An identifier to denote a specific Credential Format in the context of this specification. This identifier implies the use of parameters specific to the respective Credential Format.¶ + +Credential Issuer: + An entity that issues Credentials. Also called Issuer.¶ + +Cryptographic Holder Binding: + Ability of the Holder to prove legitimate possession of a Credential by proving control over the same private key during the issuance and presentation. Mechanism might depend on the Credential Format. For example, in jwt_vc_json Credential Format, a Credential with Cryptographic Holder Binding contains a public key or a reference to a public key that matches to the private key controlled by the Holder.¶ + +Digital Credentials API: + The Digital Credentials API (DC API) refers to the W3C Digital Credentials API [W3C.Digital_Credentials_API] on the Web Platform and its equivalent native APIs on App Platforms (such as Credential Manager on Android).¶ + +Holder: + An entity that receives Credentials and has control over them to present them to the Verifiers as Presentations.¶ + +Holder Binding or Key Binding: + Ability of the Holder to prove legitimate possession of a Credential.¶ + +Issuer-Holder-Verifier Model: + A model for exchanging claims, where claims are issued in the form of Credentials independent of the process of presenting them as Presentations to the Verifiers. An issued Credential may be used multiple times.¶ + +Origin: + An identifier for the calling website or native application, asserted by the web or app platform. A web origin is the combination of a scheme/protocol, host, and port, with port being omitted when it matches the default port of the scheme. An app platform may use a linked web origin, or use a platform-specific URI for the app origin. For example, the Verifier for the organization MyExampleOrg is served from https://verify.example.com. The web origin is https://verify.example.com with https being the scheme, verify.example.com being the host, and the port is not explicitly included as 443 is the default port for the protocol https. The native applications origin on some platforms will also be https://verify.example.com and on other platforms, may be platform:pkg-key-hash:Z4OFzVVSZrzTRa3eg79hUuHy12MVW0vzPDf4q4zaPs0.¶ + +Presentation: + Data that is presented to a specific Verifier, derived from a Credential. In this specification, Presentations are usually Verifiable Presentations including Holder Binding (as defined below), but may also be Presentations without Holder Binding (discussed in Section 5.3).¶ + +VP Token: + An artifact containing one or more Presentations returned as a response to an Authorization Request. The structure of VP Tokens is defined in Section 8.1.¶ + +Verifier: + An entity that requests, receives, and validates Presentations. The Verifier is a specific case of an OAuth 2.0 Client, just like a Relying Party (RP) in [OpenID.Core].¶ + +Verifiable Credential (VC): + An Issuer-signed Credential whose authenticity can be cryptographically verified. Can be of any format used in the Issuer-Holder-Verifier Model, including, but not limited to those defined in [VC_DATA] (VCDM), [ISO.18013-5] (mdoc), and [I-D.ietf-oauth-sd-jwt-vc] (SD-JWT VC).¶ + +Verifiable Presentation (VP): + A Presentation with a cryptographic proof of Holder Binding. Can be of any format used in the Issuer-Holder-Verifier Model, including, but not limited to those defined in [VC_DATA] (VCDM), [ISO.18013-5] (mdoc), and [I-D.ietf-oauth-sd-jwt-vc] (SD-JWT VC).¶ + +Wallet: + An entity used by the Holder to receive, store, present, and manage Credentials and key material. There is no single deployment model of a Wallet: Credentials and keys can both be stored/managed locally, or by using a remote self-hosted service, or a remote third-party service.¶ + +3. Overview + +This specification defines a mechanism to request and present Credentials. The baseline of the protocol uses HTTPS messages and redirects as defined in OAuth 2.0. Additionally, the specification defines a separate mechanism where OpenID4VP messages are sent and received over the Digital Credentials API (DC API) [W3C.Digital_Credentials_API] instead of HTTPS messages and redirects.¶ + +As the primary extension, OpenID for Verifiable Presentations introduces the new response type vp_token, which allows a Verifier to request and receive Verifiable Presentations and Presentations in a container designated as VP Token. A VP Token contains one or more Verifiable Presentations and/or Presentations in the same or different Credential formats. Consequently, the result of an OpenID4VP interaction is one or more Verifiable Presentations and/or Presentations instead of an Access Token.¶ + +This specification supports any Credential format used in the Issuer-Holder-Verifier Model, including, but not limited to those defined in [VC_DATA] (VCDM), [ISO.18013-5] (mdoc), and [I-D.ietf-oauth-sd-jwt-vc] (SD-JWT VC). Credentials of multiple formats can be presented in the same transaction. The examples given in the main part of this specification use W3C Verifiable Credentials, while examples in other Credential formats are given in Appendix B.¶ + +OpenID for Verifiable Presentations supports scenarios where the Authorization Request is sent both when the Verifier is interacting with the End-User using the device that is the same or different from the device on which requested Credential(s) are stored.¶ + +This specification supports the response being sent using a redirect but also using an HTTP POST request. This enables the response to be sent across devices, or when the response size exceeds the redirect URL character size limitation.¶ + +In summary, OpenID for Verifiable Presentations is a framework that requires profiling +to achieve interoperability. Profiling means defining:¶ + +what optional features are used or mandatory to implement, e.g., response encryption;¶ + + which values are permitted for parameters, e.g., Credential Format Identifiers;¶ + + optionally, extensions for new features.¶ + +3.1. Same Device Flow + +Figure 1 is a diagram of a flow where the End-User presents a Credential to a Verifier interacting with the End-User on the same device that the device the Wallet resides on.¶ + +The flow utilizes simple redirects to pass Authorization Request and Response between the Verifier and the Wallet. The Presentations are returned to the Verifier in the fragment part of the redirect URI, when Response Mode is fragment.¶ + +Note: The diagram does not illustrate all the optional features of this specification.¶ + ++--------------+ +--------------+ +--------------+ +| End-User | | Verifier | | Wallet | ++--------------+ +--------------+ +--------------+ + | | | + | Interacts | | + |---------------->| | + | | (1) Authorization Request | + | | (DCQL query) | + | |-------------------------------------------------->| + | | | + | | | + | End-User Authentication / Consent | + | | | + | | (2) Authorization Response | + | | (VP Token with Presentation(s)) | + | |<--------------------------------------------------| + +Figure 1: +Same Device Flow + +(1) The Verifier sends an Authorization Request to the Wallet. It contains a Digital Credentials Query Language (DCQL, see Section 6) query that describes the requirements of the Credential(s) that the Verifier is requesting to be presented. Such requirements could include what type of Credential(s), in what format(s), which individual Claims within those Credential(s) (Selective Disclosure), etc. The Wallet processes the Authorization Request and determines what Credentials are available matching the Verifier's request. The Wallet also authenticates the End-User and gathers consent to present the requested Credentials.¶ + +(2) The Wallet prepares the Presentation(s) of the Credential(s) that the End-User has consented to. It then sends to the Verifier an Authorization Response where the Presentation(s) are contained in the vp_token parameter.¶ + +3.2. Cross Device Flow + +Figure 2 is a diagram of a flow where the End-User presents a Credential to a Verifier interacting with the End-User on a different device as the device the Wallet resides on.¶ + +In this flow, the Verifier prepares an Authorization Request and renders it as a QR Code. The End-User then uses the Wallet to scan the QR Code. The Presentations are sent to the Verifier in a direct HTTP POST request to a URL controlled by the Verifier. The flow uses the Response Type vp_token in conjunction with the Response Mode direct_post, both defined in this specification. In order to keep the size of the QR Code small and be able to sign and optionally encrypt the Request Object, the actual Authorization Request contains only the Client Identifier and Request URI (as required by [RFC9101]), which the Wallet uses to retrieve the actual Authorization Request data.¶ + +Note: The diagram illustrates neither all parameters nor all optional features of this specification.¶ + +Note: The usage of the Request URI as defined in [RFC9101] does not depend on any other choices made in the protocol extensibility points, i.e., it can be used in the Same Device Flow, too.¶ + ++--------------+ +--------------+ +--------------+ +| End-User | | Verifier | | Wallet | +| | | (device A) | | (device B) | ++--------------+ +--------------+ +--------------+ + | | | + | Interacts | | + |---------------->| | + | | (1) Authorization Request | + | | (Request URI) | + | |-------------------------------------------------->| + | | | + | | (2) Request the Request Object | + | |<--------------------------------------------------| + | | | + | | (2.5) Respond with the Request Object | + | | (DCQL query) | + | |-------------------------------------------------->| + | | | + | End-User Authentication / Consent | + | | | + | | (3) Authorization Response as HTTP POST | + | | (VP Token with Presentation(s)) | + | |<--------------------------------------------------| + +Figure 2: +Cross Device Flow + +(1) The Verifier sends to the Wallet an Authorization Request that contains a Request URI from where to obtain the Request Object containing Authorization Request parameters.¶ + +(2) The Wallet sends an HTTP GET request to the Request URI to retrieve the Request Object.¶ + +(2.5) The HTTP GET response returns the Request Object containing Authorization Request parameters. It contains a DCQL query that describes the requirements of the Credential(s) that the Verifier is requesting to be presented. Such requirements could include what type of Credential(s), in what format(s), which individual Claims within those Credential(s) (Selective Disclosure), etc. The Wallet processes the Request Object and determines what Credentials are available matching the Verifier's request. The Wallet also authenticates the End-User and gathers their consent to present the requested Credentials.¶ + +(3) The Wallet prepares the Presentation(s) of the Credential(s) that the End-User has consented to. It then sends to the Verifier an Authorization Response where the Presentation(s) are contained in the vp_token parameter.¶ + +4. Scope + +OpenID for Verifiable Presentations extends existing OAuth 2.0 mechanisms in the following ways:¶ + +A new query language, the Digital Credentials Query Language (DCQL), is defined to enable requesting Presentations in an easier and more flexible way. See Section 6 for more details.¶ + + A new dcql_query Authorization Request parameter is defined to request Presentation of Credentials in the JSON-encoded DCQL format. See Section 5 for more details.¶ + + A new vp_token response parameter is defined to return Presentations with or without Holder Binding to the Verifier in either Authorization or Token Response depending on the Response Type. See Section 8 for more details.¶ + + New Response Types vp_token and vp_token id_token are defined to request Credentials to be returned in the Authorization Response (standalone or along with a Self-Issued ID Token [SIOPv2]). See Section 8 for more details.¶ + + A new OAuth 2.0 Response Mode direct_post is defined to support sending the response across devices, or when the size of the response exceeds the redirect URL character size limitation. See Section 8.2 for more details.¶ + + The format parameter is used throughout the protocol in order to enable customization according to the specific needs of a particular Credential format. Examples in Appendix B are given for Credential formats as specified in [VC_DATA], [ISO.18013-5], and [I-D.ietf-oauth-sd-jwt-vc].¶ + + The concept of a Client Identifier Prefix to enable deployments of this specification to use different mechanisms to obtain and validate metadata of the Verifier beyond the scope of [RFC6749].¶ + + A mechanism specifying the use of OpenID4VP with the Digital Credentials API (see Appendix A).¶ + +Presentation of Credentials using OpenID for Verifiable Presentations can be combined with the End-User authentication using [SIOPv2], and the issuance of OAuth 2.0 Access Tokens.¶ + +5. Authorization Request + +The Authorization Request follows the definition given in [RFC6749] taking into account the recommendations given in [RFC9700] where applicable.¶ + +The Verifier MAY send an Authorization Request as a Request Object either by value or by reference, as defined in the JWT-Secured Authorization Request (JAR) [RFC9101]. Verifiers MUST include the typ Header Parameter in Request Objects with the value oauth-authz-req+jwt, as defined in [RFC9101]. Wallets MUST NOT process Request Objects where the typ Header Parameter is not present or does not have the value oauth-authz-req+jwt.¶ + +The client_id claim is required as defined below and would be redundant with a possible iss claim in the Request Object which is commonly used in JAR. To avoid breaking existing JAR implementations, the iss claim MAY be present in the Request Object. However, if it is present, the Wallet MUST ignore it.¶ + +This specification defines a new mechanism for the cases when the Wallet wants to provide to the Verifier details about its technical capabilities to +allow the Verifier to generate a request that matches the technical capabilities of that Wallet. +To enable this, the Authorization Request can contain a request_uri_method parameter with the value post +that signals to the Wallet that it can make an HTTP POST request to the Verifier's request_uri +endpoint with information about its capabilities as defined in Section 5.10. The Wallet MAY continue with JAR +when it receives request_uri_method parameter with the value post but does not support this feature.¶ + +The Verifier articulates requirements of the Credential(s) that are requested using the dcql_query parameter. Wallet implementations MUST process the DCQL query and select candidate Credential(s) using the evaluation process described in Section 6.4¶ + +The Verifier communicates a Client Identifier Prefix that indicates how the Wallet is supposed to interpret the Client Identifier and associated data in the process of Client identification, authentication, and authorization as a prefix in the client_id parameter. This enables deployments of this specification to use different mechanisms to obtain and validate Client metadata beyond the scope of [RFC6749]. A certain Client Identifier Prefix sets the requirements whether the Verifier needs to sign the Authorization Request as a means of authentication and/or pass additional parameters and require the Wallet to process them.¶ + +Depending on the Client Identifier Prefix, the Verifier can communicate a JSON object with its metadata using the client_metadata parameter which contains name/value pairs.¶ + +Additional request parameters, other than those defined in this section, MAY be defined and used, as described in [RFC6749]. +The Wallet MUST ignore any unrecognized parameters, other than the transaction_data parameter. +One exception to this rule is the transaction_data parameter. Wallets that do not support this parameter MUST reject requests that contain it.¶ + +5.1. New Parameters + +This specification defines the following new request parameters:¶ + +dcql_query: + + A JSON object containing a DCQL query as defined in Section 6.¶ + +Either a dcql_query or a scope parameter representing a DCQL Query MUST be present in the Authorization Request, but not both.¶ + +In the context of an authorization request according to [RFC6749], parameters containing objects are transferred as JSON-serialized strings (using the application/x-www-form-urlencoded format as usual for request parameters).¶ + +client_metadata: + + OPTIONAL. A JSON object containing the Verifier metadata values. It MUST be UTF-8 encoded. The following metadata parameters MAY be used:¶ + + jwks: OPTIONAL. A JSON Web Key Set, as defined in [RFC7591], that contains one or more public keys, such as those used by the Wallet as an input to a key agreement that may be used for encryption of the Authorization Response (see Section 8.3), or where the Wallet will require the public key of the Verifier to generate a Verifiable Presentation. This allows the Verifier to pass ephemeral keys specific to this Authorization Request. Public keys included in this parameter MUST NOT be used to verify the signature of signed Authorization Requests. Each JWK in the set MUST have a kid (Key ID) parameter that uniquely identifies the key within the context of the request.¶ + + encrypted_response_enc_values_supported: OPTIONAL. Non-empty array of strings, where each string is a JWE [RFC7516] enc algorithm that can be used as the content encryption algorithm for encrypting the Response. When a response_mode requiring encryption of the Response (such as dc_api.jwt or direct_post.jwt) is specified, this MUST be present for anything other than the default single value of A128GCM. Otherwise, this SHOULD be absent.¶ + + vp_formats_supported: REQUIRED when not available to the Wallet via another mechanism. As defined in Section 11.1.¶ + +Authoritative data the Wallet is able to obtain about the Client from other sources, for example those from an OpenID Federation Entity Statement, take precedence over the values passed in client_metadata.¶ + +Other metadata parameters MUST be ignored unless a profile of this specification explicitly defines them as usable in the client_metadata parameter.¶ + +request_uri_method: + + OPTIONAL. A string determining the HTTP method to be used when the request_uri parameter is included in the same request. Two case-sensitive valid values are defined in this specification: get and post. If request_uri_method value is get, the Wallet MUST send the request to retrieve the Request Object using the HTTP GET method, i.e., as defined in [RFC9101]. If request_uri_method value is post, a supporting Wallet MUST send the request using the HTTP POST method as detailed in Section 5.10. If the request_uri_method parameter is not present, the Wallet MUST process the request_uri parameter as defined in [RFC9101]. Wallets not supporting the post method will send a GET request to the Request URI (default behavior as defined in [RFC9101]). request_uri_method parameter MUST NOT be present if a request_uri parameter is not present.¶ + +If the Verifier set the request_uri_method parameter value to post and there is no other means to convey its capabilities to the Wallet, it SHOULD add the client_metadata parameter to the Authorization Request. +This enables the Wallet to assess the Verifier's capabilities, allowing it to transmit only the relevant capabilities through the wallet_metadata parameter in the Request URI POST request.¶ + +transaction_data: + + OPTIONAL. Non-empty array of strings, where each string is a base64url-encoded JSON object that contains a typed parameter set with details about the transaction that the Verifier is requesting the End-User to authorize. See Section 8.4 for details. The Wallet MUST return an error if a request contains even one unrecognized transaction data type or transaction data not conforming to the respective type definition. In addition to the parameters determined by the type of transaction data, each transaction_data object consists of the following parameters defined by this specification:¶ + + type: REQUIRED. String that identifies the type of transaction data. This value determines parameters that can be included in the transaction_data object. The specific values are out of scope for this specification. It is RECOMMENDED to use collision-resistant names for type values.¶ + + credential_ids: REQUIRED. Non-empty array of strings each referencing a Credential requested by the Verifier that can be used to authorize this transaction. The string matches the id field in the DCQL Credential Query. If there is more than one element in the array, the Wallet MUST use only one of the referenced Credentials for transaction authorization.¶ + +Each document specifying details of a transaction data type defines what Credential(s) can be used to authorize those transactions. Those Credential(s) can be issued specifically for the transaction authorization use case or re-use existing Credential(s) used for user identification. A mechanism for Credential Issuers to express that a particular Credential can be used for authorization of transaction data is out of scope for this specification.¶ + +The following is a non-normative example of a transaction data content, after base64url decoding one of the strings in the transaction_data parameter:¶ + +{ + "type": "example_type", + "credential_ids": [ "id_card_credential" ], + // other transaction data type specific parameters +} +¶ + +verifier_info: + + OPTIONAL. A non-empty array of attestations about the Verifier relevant to the Credential Request. These attestations MAY include Verifier metadata, policies, trust status, or authorizations. Attestations are intended to support authorization decisions, inform Wallet policy enforcement, or enrich the End-User consent dialog. Each object has the following structure:¶ + + format: REQUIRED. A string that identifies the format of the attestation and how it is encoded. Ecosystems SHOULD use collision-resistant identifiers. Further processing of the attestation is determined by the type of the attestation, which is specified in a format-specific way.¶ + + data: REQUIRED. An object or string containing an attestation (e.g. a JWT). The payload structure is defined on a per format level. It is at the discretion of the Wallet whether it uses the information from verifier_info. Factors that influence such Wallet's decision include, but are not limited to, trust framework the Wallet supports, specific policies defined by the Issuers or ecosystem, and profiles of this specification. If the Wallet uses information from verifier_info, the Wallet MUST validate the signature and ensure binding.¶ + + credential_ids: OPTIONAL. A non-empty array of strings each referencing a Credential requested by the Verifier for which the attestation is relevant. Each string matches the id field in a DCQL Credential Query. If omitted, the attestation is relevant to all requested Credentials.¶ + +See Section 5.11 for more details.¶ + +The following is a non-normative example of an attested object:¶ + +{ + "format": "jwt", + "data": "eyJhbGciOiJFUzI1...EF0RBtvPClL71TWHlIQ", + "credential_ids": [ "id_card" ] +} +¶ + +5.2. Existing Parameters + +The following additional considerations are given for pre-existing Authorization Request parameters:¶ + +nonce: + REQUIRED. A case-sensitive String representing a value to securely bind Verifiable Presentation(s) provided by the Wallet to the particular transaction. The Verifier MUST create a fresh, cryptographically random number with sufficient entropy for every Authorization Request, store it with its current session, and pass it in the nonce Authorization Request Parameter to the Wallet. See Section 14.1 for details. Values MUST only contain ASCII URL safe characters (uppercase and lowercase letters, decimal digits, hyphen, period, underscore, and tilde).¶ + +scope: + OPTIONAL. Defined in [RFC6749]. The Wallet MAY allow Verifiers to request Presentations by utilizing a pre-defined scope value. See Section 5.5 for more details.¶ + +response_mode: + REQUIRED. Defined in [OAuth.Responses]. This parameter can be used (through the new Response Mode direct_post) to ask the Wallet to send the response to the Verifier via an HTTPS connection (see Section 8.2 for more details). It can also be used to request that the resulting response be encrypted (see Section 8.3 for more details).¶ + +client_id: + REQUIRED. Defined in [RFC6749]. This specification defines additional requirements to enable the use of Client Identifier Prefixes as described in Section 5.9. The Client Identifier can be created by parties other than the Wallet and it is considered unique within the context of the Wallet when used in combination with the Client Identifier Prefix.¶ + +state: + REQUIRED under the conditions defined in Section 5.3. Otherwise, state is OPTIONAL. state values MUST only contain ASCII URL safe characters (uppercase and lowercase letters, decimal digits, hyphen, period, underscore, and tilde).¶ + +5.3. Requesting Presentations without Holder Binding Proofs + +The primary use case of this specification is to request and present Verifiable +Presentations, i.e., Presentations that contain a cryptographic Holder Binding proof.¶ + +However, there are use cases where the Verifier wants to request presentation of +Credentials without a proof of cryptographic Holder Binding. Examples for such use cases include +low-security Credentials that do not support Holder Binding (e.g., a cinema +ticket), Credentials that are bound to a biometric trait, or Credentials that +are bound to claims (e.g., a diploma). In some cases, Credentials may support +Holder Binding, but the Verifier may not require it for the Presentation.¶ + +A Verifier that requests and accepts a Presentation of a Credential without a +proof of Holder Binding accepts that the presented Credential may have been +replayed. Section 14.1 contains additional considerations for this case.¶ + +To request a Credential without proof of Holder Binding, the Verifier uses the require_cryptographic_holder_binding parameter in the DCQL request as defined in Section 6 and +Appendix B.¶ + +In this protocol, the nonce parameter serves to securely link the request and +response and as a replay protection in the Holder Binding proof. Without the key +binding proof, nonce is not returned in the response. To maintain the binding +between request and response, the Verifier MUST¶ + +include a state parameter as defined in Section 4.1.1 of [RFC6749] in the +Authorization Request,¶ + + ensure that the value is a cryptographically strong pseudo-random number with +at least 128 bits of entropy,¶ + + ensure that the value is chosen fresh for each Authorization Request,¶ + + store it in the Verifier's session state, and¶ + + check that the same state value is returned in the Authorization Response,¶ + +if at least one Presentation without Holder Binding is requested and unless the +Digital Credentials API is used. The Digital Credentials API uses internal +mechanisms to maintain the binding.¶ + +When using Response Mode direct_post, also see +Section 14.3.¶ + +5.4. Examples + +The Verifier MAY send an Authorization Request using either of these 3 options:¶ + +Passing as URL with encoded parameters¶ + + Passing a request object as value¶ + + Passing a request object by reference¶ + +The second and third options are defined in the JWT-Secured Authorization Request (JAR) [RFC9101].¶ + +The following is a non-normative example of an Authorization Request with URL-encoded parameters:¶ + +GET /authorize? + response_type=vp_token + &client_id=redirect_uri%3Ahttps%3A%2F%2Fclient.example.org%2Fcb + &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb + &dcql_query=... + &transaction_data=... + &nonce=n-0S6_WzA2Mj HTTP/1.1 +¶ + +The following is a non-normative example of an Authorization Request with a Request Object passed by value:¶ + +GET /authorize? + client_id=redirect_uri%3Ahttps%3A%2F%2Fclient.example.org%2Fcb + &request=eyJrd... +¶ + +Where the contents of the request query parameter consist of a base64url-encoded and signed (in the example with RS256 algorithm) Request Object. The decoded payload is:¶ + +{ + "iss": "redirect_uri:https://client.example.org/cb", + "aud": "https://self-issued.me/v2", + "response_type": "vp_token", + "client_id": "redirect_uri:https://client.example.org/cb", + "redirect_uri": "https//client.example.org/cb", + "dcql_query": { + "credentials": [ + { + "id": "some_identity_credential", + "format": "dc+sd-jwt", + "meta": { + "vct_values": [ "https://credentials.example.com/identity_credential" ] + }, + "claims": [ + {"path": ["last_name"]}, + {"path": ["first_name"]} + ] + } + ] + }, + "nonce": "n-0S6_WzA2Mj" +} +¶ + +The following is a non-normative example of an Authorization Request with a request object passed by reference:¶ + +GET /authorize? + client_id=x509_san_dns%3Aclient.example.org + &request_uri=https%3A%2F%2Fclient.example.org%2Frequest%2Fvapof4ql2i7m41m68uep + &request_uri_method=post HTTP/1.1 +¶ + +To retrieve the actual request, the Wallet might send the following non-normative example HTTP request to the request_uri:¶ + +POST /request/vapof4ql2i7m41m68uep HTTP/1.1 +Host: client.example.org +Content-Type: application/x-www-form-urlencoded + +wallet_metadata=%7B%22vp_formats_supported%22%3A%7B%22dc%2Bsd-jwt%22%3A%7B%22sd-jwt_alg +_values%22%3A%20%5B%22ES256%22%5D%2C%22kb-jwt_alg_values%22%3A%20%5B%22ES256%22%5D%7D%7 +D%7D& +wallet_nonce=qPmxiNFCR3QTm19POc8u +¶ + +5.5. Using scope Parameter to Request Presentations + +Wallets MAY support requesting Presentations using OAuth 2.0 scope values.¶ + +Such a scope parameter value MUST be an alias for a well-defined DCQL query. Since multiple scope values can be used at the same time, the identifiers for Credentials (see Section 6.1) and claims (see Section 6.3) within the DCQL queries associated with scope values MUST be unique. This ensures that there are no collisions between the identifiers used in the DCQL queries and that the Verifier can unambiguously identify the requested Credentials in the response.¶ + +The specific scope values, and the mapping between a certain scope value and the respective +DCQL query, are out of scope of this specification.¶ + +Possible options include normative text in a separate specification defining scope values along with a description of their +semantics or machine-readable definitions in the Wallet's server metadata, mapping a scope value to an equivalent +DCQL request.¶ + +It is RECOMMENDED to use collision-resistant scopes values.¶ + +The following is a non-normative example of an Authorization Request using the example scope value com.example.IDCardCredential_presentation:¶ + +GET /authorize? + response_type=vp_token + &client_id=https%3A%2F%2Fclient.example.org%2Fcb + &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb + &scope=com.example.healthCardCredential_presentation + &nonce=n-0S6_WzA2Mj HTTP/1.1 +¶ + +5.6. Response Type vp_token + +This specification defines the Response Type vp_token.¶ + +vp_token: + When supplied as the response_type parameter in an Authorization Request, a successful response MUST include the vp_token parameter. The Wallet SHOULD NOT return an OAuth 2.0 Authorization Code, Access Token, or Access Token Type in a successful response to the grant request. The default Response Mode for this Response Type is fragment, i.e., the Authorization Response parameters are encoded in the fragment added to the redirect_uri when redirecting back to the Verifier. The Response Type vp_token can be used with other Response Modes as defined in [OAuth.Responses]. Both successful and error responses SHOULD be returned using the supplied Response Mode, or if none is supplied, using the default Response Mode.¶ + +See Section 8 on how the response_type value determines the response used to return a VP Token.¶ + +5.7. Passing Authorization Request Across Devices + +There are use-cases when the Authorization Request is being displayed on a device different from a device on which the requested Credential is stored. In those cases, an Authorization Request can be passed across devices by being rendered as a QR Code.¶ + +The usage of the Response Mode direct_post (see Section 8.2) in conjunction with request_uri is RECOMMENDED, since Authorization Request size might be large and might not fit in a QR code.¶ + +5.8. aud of a Request Object + +When the Verifier is sending a Request Object as defined in [RFC9101], the aud claim value depends on whether the recipient of the request can be identified by the Verifier or not:¶ + +the aud claim MUST be equal to the iss (issuer) claim value, when Dynamic Discovery is performed.¶ + + the aud claim MUST be "https://self-issued.me/v2", when Static Discovery metadata is used.¶ + +Note: "https://self-issued.me/v2" is a symbolic string and can be used as an aud claim value even when this specification is used standalone, without SIOPv2.¶ + +5.9. Client Identifier Prefix and Verifier Metadata Management + +This specification defines the concept of a Client Identifier Prefix that dictates how the Wallet needs to interpret the Client Identifier and associated data in the process of Client identification, authentication, and authorization. +The Client Identifier Prefix enables deployments of this specification to use different mechanisms to obtain and validate metadata of the Verifier beyond the scope of [RFC6749]. The term Client Identifier Prefix is used since the Verifier is acting as an OAuth 2.0 Client.¶ + +The Client Identifier Prefix is a string that MAY be communicated by the Verifier in a prefix within the client_id parameter in the Authorization Request. A fallback to pre-registered Clients as in [RFC6749] remains in place as a default mechanism in case no Client Identifier Prefix was provided. A certain Client Identifier Prefix may require the Verifier to sign the Authorization Request as a means of authentication and/or pass additional parameters and require the Wallet to process them.¶ + +5.9.1. Syntax + +In the client_id Authorization Request parameter and other places where the Client Identifier is used, the Client Identifier Prefixes are prefixed to the usual Client Identifier, separated by a : (colon) character:¶ + +: +¶ + +Here, is the Client Identifier Prefix and is an identifier for the Client within the namespace of that prefix. See Section 5.9.3 for Client Identifier Prefixes defined by this specification.¶ + +Wallets MUST use the presence of a : (colon) character and the content preceding it to determine whether a Client Identifier Prefix is used. If a : character is present and the content preceding it is a recognized and supported Client Identifier Prefix value, the Wallet MUST interpret the Client Identifier according to the given Client Identifier Prefix. The Client Identifier Prefix is defined as the string before the (first) : character. Note that implementations should not assume that the presence of a : character implies that the entire value can be processed as a valid URI. Instead, the specific processing rules defined for the specified Client Identifier Prefix (see Section 5.9.3) should be used to parse the client_id value.¶ + +For example, an Authorization Request might contain client_id=verifier_attestation:example-client to indicate that the verifier_attestation Client Identifier Prefix is to be used and that within this prefix, the Verifier can be identified by the string example-client. The presentation would contain the full verifier_attestation:example-client string as the audience (intended receiver) and the same full string would be used as the Client Identifier anywhere in the OAuth flow.¶ + +Note that the Verifier needs to determine which Client Identifier Prefixes the Wallet supports prior to sending the Authorization Request in order to choose a supported prefix.¶ + +Depending on the Client Identifier Prefix, the Verifier can communicate a JSON object with its metadata using the client_metadata parameter which contains name/value pairs.¶ + +5.9.2. Fallback + +If a : character is not present in the Client Identifier, the Wallet MUST treat the Client Identifier as referencing a pre-registered client. This is equivalent to the [RFC6749] default behavior, i.e., the Client Identifier needs to be known to the Wallet in advance of the Authorization Request. The Verifier metadata is obtained using [RFC7591] or through out-of-band mechanisms.¶ + +For example, if an Authorization Request contains client_id=example-client, the Wallet would interpret the Client Identifier as referring to a pre-registered client.¶ + +If a : character is present in the Client Identifier but the value preceding it is not a recognized and supported Client Identifier Prefix value, the Wallet can treat the Client Identifier as referring to a pre-registered client or it may refuse the request.¶ + +From this definition, it follows that pre-registered clients MUST NOT contain a : character preceded immediately by a supported Client Identifier Prefix value in the first part of their Client Identifier.¶ + +5.9.3. Defined Client Identifier Prefixes + +This specification defines the following Client Identifier Prefixes, followed by the examples where applicable.¶ + +In case of using OpenID4VP over DC API, as defined in Appendix A, it is at the discretion of the Wallet whether it validates the signature on the Request Object following the processing rules defined by a relevant Client Identifier Prefix. Factors that influence the Wallet's decision include, but are not limited to, the trust framework the Wallet supports, the specific policies defined by the Issuers or ecosystem, and profiles of this specification.¶ + + redirect_uri: This prefix value indicates that the original Client Identifier part (without the prefix redirect_uri:) is the Verifier's Redirect URI (or Response URI when Response Mode direct_post is used). The Verifier MAY omit the redirect_uri Authorization Request parameter (or response_uri when Response Mode direct_post is used). All Verifier metadata parameters MUST be passed using the client_metadata parameter defined in Section 5.1. An example Client Identifier value is redirect_uri:https://client.example.org/cb. Requests using the redirect_uri Client Identifier Prefix cannot be signed because there is no method for the Wallet to obtain a trusted key for verification. Therefore, implementations requiring signed requests cannot use the redirect_uri Client Identifier Prefix.¶ + +The following is a non-normative example of an unsigned request with the redirect_uri Client Identifier Prefix:¶ + +HTTP/1.1 302 Found +Location: https://wallet.example.org/universal-link? + response_type=vp_token + &client_id=redirect_uri%3Ahttps%3A%2F%2Fclient.example.org%2Fcb + &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb + &dcql_query=... + &nonce=n-0S6_WzA2Mj + &client_metadata=%7B%22vp_formats_supported%22%3A%7B%22dc%2Bsd-jwt%22%3A%7B%22sd-jwt_ + alg_values%22%3A%20%5B%22ES256%22%5D%2C%22kb-jwt_alg_values%22%3A%20%5B%22ES256%22%5D + %7D%7D%7D +¶ + + openid_federation: This prefix value indicates that the original Client Identifier (the part without the prefix openid_federation:) is an Entity Identifier defined in OpenID Federation [OpenID.Federation]. Processing rules given in [OpenID.Federation] MUST be followed. The Authorization Request MAY also contain a trust_chain parameter. The final Verifier metadata is obtained from the Trust Chain after applying the policies, according to [OpenID.Federation]. The client_metadata parameter, if present in the Authorization Request, MUST be ignored when this Client Identifier Prefix is used. Example Client Identifier: openid_federation:https://federation-verifier.example.com.¶ + + decentralized_identifier: This prefix value indicates that the original Client Identifier (the part without the prefix decentralized_identifier:) is a Decentralized Identifier as defined in [DID-Core]. The request MUST be signed with a private key associated with the DID. A public key to verify the signature MUST be obtained from the verificationMethod property of a DID Document. Since DID Document may include multiple public keys, a particular public key used to sign the request in question MUST be identified by the kid in the JOSE Header. To obtain the DID Document, the Wallet MUST use DID Resolution defined by the DID method used by the Verifier. All Verifier metadata other than the public key MUST be obtained from the client_metadata parameter as defined in Section 5.1. Example Client Identifier: decentralized_identifier:did:example:123.¶ + +The following is a non-normative example of a header and a body of a signed Request Object when the Client Identifier Prefix is decentralized_identifier:¶ + +Header¶ + +{ + "typ": "oauth-authz-req+jwt", + "alg": "RS256", + "kid": "did:example:123#1" +} +¶ + +Body¶ + +{ + "client_id": "decentralized_identifier:did:example:123", + "response_type": "vp_token", + "redirect_uri": "https://client.example.org/callback", + "nonce": "n-0S6_WzA2Mj", + "dcql_query": { ... }, + "client_metadata": { + "vp_formats_supported": { + "dc+sd-jwt": { + "sd-jwt_alg_values": ["ES256", "ES384"], + "kb-jwt_alg_values": ["ES256", "ES384"] + } + } + } +} +¶ + + verifier_attestation: This Client Identifier Prefix allows the Verifier to authenticate using a JWT that is bound to a certain public key as defined in Section 12. When the Client Identifier Prefix is verifier_attestation, the original Client Identifier (the part without the verifier_attestation: prefix) MUST equal the sub claim value in the Verifier attestation JWT. The request MUST be signed with the private key corresponding to the public key in the cnf claim in the Verifier attestation JWT. This serves as proof of possession of this key. The Verifier attestation JWT MUST be added to the jwt JOSE Header of the request object (see Section 12). The Wallet MUST validate the signature on the Verifier attestation JWT. The iss claim value of the Verifier Attestation JWT MUST identify a party the Wallet trusts for issuing Verifier Attestation JWTs. If the Wallet cannot establish trust, it MUST refuse the request. If the issuer of the Verifier Attestation JWT adds a redirect_uris claim to the attestation, the Wallet MUST ensure the redirect_uri request parameter value exactly matches one of the redirect_uris claim entries. All Verifier metadata other than the public key MUST be obtained from the client_metadata parameter. Example Client Identifier: verifier_attestation:verifier.example.¶ + + x509_san_dns: When the Client Identifier Prefix is x509_san_dns, the original Client Identifier (the part after the x509_san_dns: prefix) MUST be a DNS name and match a dNSName Subject Alternative Name (SAN) [RFC5280] entry in the leaf certificate passed with the request. The request MUST be signed with the private key corresponding to the public key in the leaf X.509 certificate of the certificate chain added to the request in the x5c JOSE header [RFC7515] of the signed request object. The Wallet MUST validate the signature and the trust chain of the X.509 certificate. All Verifier metadata other than the public key MUST be obtained from the client_metadata parameter. The following requirement applies unless the interaction is using the DC API as defined in Appendix A: If the Wallet can establish trust in the Client Identifier authenticated through the certificate, e.g. because the Client Identifier is contained in a list of trusted Client Identifiers, it may allow the client to freely choose the redirect_uri value. If not, the FQDN of the redirect_uri value MUST match the Client Identifier without the prefix x509_san_dns:. Example Client Identifier: x509_san_dns:client.example.org.¶ + + x509_hash: When the Client Identifier Prefix is x509_hash, the original Client Identifier (the part without the x509_hash: prefix) MUST be a hash and match the hash of the leaf certificate passed with the request. The request MUST be signed with the private key corresponding to the public key in the leaf X.509 certificate of the certificate chain added to the request in the x5c JOSE header parameter [RFC7515] of the signed request object. The value of x509_hash is the base64url-encoded value of the SHA-256 hash of the DER-encoded X.509 certificate. The Wallet MUST validate the signature and the trust chain of the X.509 leaf certificate. All Verifier metadata other than the public key MUST be obtained from the client_metadata parameter. Example Client Identifier: x509_hash:Uvo3HtuIxuhC92rShpgqcT3YXwrqRxWEviRiA0OZszk¶ + + origin: This reserved Client Identifier Prefix is defined in Appendix A.2. The Wallet MUST NOT accept this Client Identifier Prefix in requests. In OpenID4VP over the Digital Credentials API, the audience of the Credential Presentation is always the origin value prefixed by origin:, for example origin:https://verifier.example.com/.¶ + +To use the Client Identifier Prefixes openid_federation, decentralized_identifier, verifier_attestation, x509_san_dns and x509_hash, Verifiers MUST be capable of securely storing private key material. This might require changes to the technical design of native apps as such apps are typically public clients.¶ + +Other specifications can define further Client Identifier Prefixes. It is RECOMMENDED to use collision-resistant names for such values.¶ + +5.10. Request URI Method post + +This request is handled by the Request URI endpoint of the Verifier.¶ + +The request MUST use the HTTP POST method with the https scheme, and the content type application/x-www-form-urlencoded and the Accept header set to application/oauth-authz-req+jwt. The names and values in the body MUST be encoded using UTF-8.¶ + +The following parameters are defined to be included in the request to the Request URI Endpoint:¶ + +wallet_metadata: + OPTIONAL. A string containing a JSON object containing metadata parameters as defined in Section 10.¶ + +wallet_nonce: + OPTIONAL. A string value used to mitigate replay attacks of the Authorization Request. When received, the Verifier MUST use it as the wallet_nonce value in the signed authorization request object. Value can be a base64url-encoded, fresh, cryptographically random number with sufficient entropy.¶ + +If the Wallet requires the Verifier to encrypt the Request Object, it SHOULD use the jwks parameter within the wallet_metadata parameter to pass public encryption keys. If the Wallet requires an encrypted Authorization Response, it SHOULD specify supported encryption algorithms using the authorization_encryption_alg_values_supported and authorization_encryption_enc_values_supported parameters.¶ + +Additionally, if the Client Identifier Prefix permits signed Request Objects, the Wallet SHOULD list supported cryptographic algorithms for securing the Request Object through the request_object_signing_alg_values_supported parameter. Conversely, the Wallet MUST NOT include this parameter if the Client Identifier Prefix precludes signed Request Objects.¶ + +Additional parameters MAY be defined and used in the request to the Request URI Endpoint. +The Verifier MUST ignore any unrecognized parameters.¶ + +The following is a non-normative example of a request:¶ + +POST /request HTTP/1.1 +Host: client.example.org +Content-Type: application/x-www-form-urlencoded + +wallet_metadata=%7B%22vp_formats_supported%22%3A%7B%22dc%2Bsd-jwt%22%3A%7B%22sd-jwt_a +lg_values%22%3A%20%5B%22ES256%22%5D%2C%22kb-jwt_alg_values%22%3A%20%5B%22ES256%22%5D% +7D%7D%7D& +wallet_nonce=qPmxiNFCR3QTm19POc8u +¶ + +5.10.1. Request URI Response + +The Request URI response MUST be an HTTP response with the content type application/oauth-authz-req+jwt and the body being a signed, optionally encrypted, request object as defined in [RFC9101]. The request object MUST fulfill the requirements as defined in Section 5.¶ + +The following is a non-normative example of a payload for a request object:¶ + +{ + "client_id": "x509_san_dns:client.example.org", + "response_uri": "https://client.example.org/post", + "response_type": "vp_token", + "response_mode": "direct_post", + "dcql_query": {...}, + "nonce": "n-0S6_WzA2Mj", + "wallet_nonce": "qPmxiNFCR3QTm19POc8u", + "state" : "eyJhb...6-sVA" +} +¶ + +The Wallet MUST process the request as defined in [RFC9101]. Additionally, if the Wallet passed a wallet_nonce in the POST request, the Wallet MUST validate whether the request object contains the respective nonce value in a wallet_nonce claim. If it does not, the Wallet MUST terminate request processing.¶ + +The Wallet MUST extract the set of Authorization Request parameters from the Request Object. The Wallet MUST only use the parameters in this Request Object, even if the same parameter was provided in an Authorization Request query parameter. The Client Identifier value in the client_id Authorization Request parameter and the Request Object client_id claim value MUST be identical, including the Client Identifier Prefix. If any of these conditions are not met, the Wallet MUST terminate request processing.¶ + +The Wallet then validates the request as specified in OAuth 2.0 [RFC6749].¶ + +5.10.2. Request URI Error Response + +If the Verifier responds with any HTTP error response, the Wallet MUST terminate the process.¶ + +5.11. Verifier Info + +Verifier Info parameter allows the Verifier to provide additional context or metadata as part of the Authorization Request attested by a trusted third party. These inputs can support a variety of use cases, such as helping the Wallet apply policy decisions, validating eligibility, or presenting more meaningful information to the End-User during consent.¶ + +Each Verifier Info object contains a type identifier, associated data and optionally references to Credential identifiers. The format and semantics of these attestations are defined by ecosystems or profiles.¶ + +For example, a Verifier might include:¶ + +A registration certificate issued by a trusted authority, to prove that the Verifier has publicly registered its intent to request certain credentials.¶ + + A policy statement, such as a signed document describing acceptable use, retention periods, or access rights.¶ + + The confirmation of a role of the Verifier in a certain domain, e.g. the Verifier might be a certified payment service provider under the EU's Payment Service Directive 2.¶ + +The Verifier Info parameter is optional. Wallets MAY use them to make authorization decisions or to enhance the user experience, but they SHOULD ignore any unrecognized or unsupported Verifier Info types.¶ + +5.11.1. Proof of Possession + +This specification supports two models for proof of possession:¶ + + claim-bound attestations: The attestation is not signed by the Verifier, but bound to it. The exact binding mechanism is defined by the type of the definition. For example for JWTs, the sub claim is including the distinguished name of the Certificate that was used to sign the request. The binding may also include the client_id parameter.¶ + + key-bound attestations: The attestation's proof of possession is signed by the Verifier with a key contained or related to the attestation. To bind the signature to the presentation request, the respective signature object should include the nonce and client_id request parameters. The attestation and the proof of possession have to be passed in the attachment.¶ + +The Wallet MUST validate such proofs if defined by the profile and ignore or reject attachments that fail validation.¶ + +6. Digital Credentials Query Language (DCQL) + +The Digital Credentials Query Language (DCQL, pronounced [ˈdakl̩]) is a +JSON-encoded query language that allows the Verifier to request +Presentations that match the query. The Verifier MAY encode constraints on the +combinations of Credentials and claims that are requested. The Wallet evaluates +the query against the Credentials it holds and returns +Presentations matching the query.¶ + +A valid DCQL query is defined as a JSON-encoded object with the following +top-level properties:¶ + +credentials: + REQUIRED. A non-empty array of Credential Queries as defined in Section 6.1 +that specify the requested Credentials.¶ + +credential_sets: + OPTIONAL. A non-empty array of Credential Set Queries as defined in Section 6.2 +that specifies additional constraints on which of the requested Credentials to return.¶ + +Note: Future extensions may define additional properties both at the top level +and in the rest of the DCQL data structure. Implementations MUST ignore any +unknown properties.¶ + +6.1. Credential Query + +A Credential Query is an object representing a request for a presentation of one or more matching +Credentials.¶ + +Each entry in credentials MUST be an object with the following properties:¶ + +id: + REQUIRED. A string identifying the Credential in the response and, if provided, +the constraints in credential_sets. The value MUST be a non-empty string +consisting of alphanumeric, underscore (_), or hyphen (-) characters. +Within the Authorization Request, the same id MUST NOT +be present more than once.¶ + +format: + REQUIRED. A string that specifies the format of the requested Credential. Valid Credential Format Identifier values are defined in +Appendix B.¶ + +multiple: + OPTIONAL. A boolean which indicates whether multiple Credentials can be returned for this Credential Query. If omitted, the default value is false.¶ + +meta: + REQUIRED. An object defining additional properties requested by the Verifier that +apply to the metadata and validity data of the Credential. The properties of +this object are defined per Credential Format. Examples of those are in Appendix B.3.5 and Appendix B.2.3. If empty, +no specific constraints are placed on the metadata or validity of the requested +Credential.¶ + +trusted_authorities: + OPTIONAL. A non-empty array of objects as defined in Section 6.1.1 that +specifies expected authorities or trust frameworks that certify Issuers, that the +Verifier will accept. Every Credential returned by the Wallet SHOULD match at least +one of the conditions present in the corresponding trusted_authorities array if present.¶ + +Note that Verifiers must verify that the issuer of a received presentation is +trusted on their own and this feature mainly aims to help data minimization by not +revealing information that would likely be rejected.¶ + +require_cryptographic_holder_binding: + OPTIONAL. A boolean which indicates whether the Verifier requires a Cryptographic Holder Binding +proof. The default value is true, i.e., a Verifiable Presentation with Cryptographic Holder Binding +is required. If set to false, the Verifier accepts a Credential without Cryptographic Holder Binding +proof.¶ + +claims: + OPTIONAL. A non-empty array of objects as defined in Section 6.3 that specifies +claims in the requested Credential. Verifiers MUST NOT point to the same claim more than +once in a single query. Wallets SHOULD ignore such duplicate claim queries.¶ + +claim_sets: + OPTIONAL. A non-empty array containing arrays of identifiers for +elements in claims that specifies which combinations of claims for the Credential are requested. +The rules for selecting claims to send are defined in Section 6.4.1.¶ + +Multiple Credential Queries in a request MAY request a presentation of the same Credential.¶ + +6.1.1. Trusted Authorities Query + +A Trusted Authorities Query is an object representing information that helps to identify an authority +or the trust framework that certifies Issuers. A Credential is identified as a match +to a Trusted Authorities Query if it matches with one of the provided values in one of the provided +types. How exactly the matching works is defined for the different types below.¶ + +Note that direct Issuer matching can also work using claim value matching if supported (e.g., value matching +the iss claim in an SD-JWT) if the mechanisms for trusted_authorities are not applicable but might +be less likely to work due to the constraints on value matching (see Section 6.4.1 for more details).¶ + +Each entry in trusted_authorities MUST be an object with the following properties:¶ + +type: + REQUIRED. A string uniquely identifying the type of information about the issuer trust framework. +Types defined by this specification are listed below.¶ + +values: + REQUIRED. A non-empty array of strings, where each string (value) contains information specific to the +used Trusted Authorities Query type that allows the identification of an issuer, a trust framework, or a federation that an +issuer belongs to.¶ + +Below are descriptions for the different Type Identifiers (string), detailing how to interpret +and perform the matching logic for each provided value.¶ + +Note that depending on the trusted authorities type used, the underlying mechanisms can have +different privacy implications. More detailed privacy considerations for the trusted authorities +can be found in Section 15.10.¶ + +6.1.1.1. Authority Key Identifier + + Type: + + "aki"¶ + +Value: + Contains the KeyIdentifier of the AuthorityKeyIdentifier as defined in Section 4.2.1.1 of [RFC5280], +encoded as base64url. The raw byte representation of this element MUST match with the AuthorityKeyIdentifier +element of an X.509 certificate in the certificate chain present in the Credential (e.g., in the header of +an mdoc or SD-JWT). Note that the chain can consist of a single certificate and the Credential can include the +entire X.509 chain or parts of it.¶ + +Below is a non-normative example of such an entry of type aki:¶ + +{ + "type": "aki", + "values": ["s9tIpPmhxdiuNkHMEWNpYim8S8Y"] +} +¶ + +6.1.1.2. ETSI Trusted List + + Type: + + "etsi_tl"¶ + +Value: + The identifier of a Trusted List as specified in ETSI TS 119 612 [ETSI.TL]. An ETSI +Trusted List contains references to other Trusted Lists, creating a list of trusted lists, or entries +for Trust Service Providers with corresponding service description and X.509 Certificates. The trust chain +of a matching Credential MUST contain at least one X.509 Certificate that matches one of the entries of the +Trusted List or its cascading Trusted Lists.¶ + +Below is a non-normative example of such an entry of type etsi_tl:¶ + +{ + "type": "etsi_tl", + "values": ["https://lotl.example.com"] +} +¶ + +6.1.1.3. OpenID Federation + + Type: + + "openid_federation"¶ + +Value: + The Entity Identifier as defined in Section 1 of [OpenID.Federation] that is bound to +an entity in a federation. While this Entity Identifier could be any entity in +that ecosystem, this entity would usually have the Entity Configuration of a Trust Anchor. +A valid trust path, including the given Entity Identifier, must be constructible from a matching credential.¶ + +Below is a non-normative example of such an entry of type openid_federation:¶ + +{ + "type": "openid_federation", + "values": ["https://trustanchor.example.com"] +} +¶ + +6.2. Credential Set Query + +A Credential Set Query is an object representing a request for one or more Credentials to satisfy +a particular use case with the Verifier.¶ + +Each entry in credential_sets MUST be an object with the following properties:¶ + +options: + REQUIRED A non-empty array, where each value in the array is a list +of Credential Query identifiers representing one set of Credentials that +satisfies the use case. The value of each element in the options array is a +non-empty array of identifiers which reference elements in credentials.¶ + +required: + OPTIONAL A boolean which indicates whether this set of Credentials is required +to satisfy the particular use case at the Verifier. If omitted, the default value is true.¶ + +Before sending the presentation request, the Verifier SHOULD display to the End-User the purpose, context, or reason for the query to the Wallet.¶ + +6.3. Claims Query + +Each entry in claims MUST be an object with the following properties:¶ + +id: + REQUIRED if claim_sets is present in the Credential Query; OPTIONAL otherwise. A string +identifying the particular claim. The value MUST be a non-empty string +consisting of alphanumeric, underscore (_), or hyphen (-) characters. +Within the particular claims array, the same id MUST NOT +be present more than once.¶ + +path: + REQUIRED The value MUST be a non-empty array representing a claims path pointer that specifies the path to a claim +within the Credential, as defined in Section 7.¶ + +values: + OPTIONAL A non-empty array of strings, integers or boolean values that specifies the expected values of the claim. +If the values property is present, the Wallet SHOULD return the claim only if the +type and value of the claim both match exactly for at least one of the elements in the array. Details of the processing +rules are defined in Section 6.4.1.¶ + +If a Wallet implements value matching and the Credential being matched is +an ISO mdoc-based credential, the CBOR value used for matching MUST first be converted to JSON, following the advice +given in Section 6.1 of [RFC8949]. The resulting JSON value is then used to match against the values property as specified above. +When conversion according to these rules is not clearly defined, behavior is out of scope of this specification.¶ + +6.4. Selecting Claims and Credentials + +The following section describes the logic that applies for selecting claims +and for selecting credentials.¶ + +For formats supporting selective disclosure, these rules support selecting a minimal +dataset to fulfill the Verifier's request in a privacy-friendly manner +(see Section 15 for additional considerations). Wallets MUST NOT send +selectively disclosable claims that have not been selected according to the rules below. +A single Presentation of a Credential MAY contain more than the claims selected in the +particular DCQL Credential Query if the same Credential is selected with the additional +claims in a separate Credential Query in the same request, or the additional claims are +not selectively disclosable.¶ + +6.4.1. Selecting Claims + +The following rules apply for selecting claims via claims and claim_sets:¶ + +If claims is absent, the Verifier is requesting no claims that are selectively disclosable; the Wallet MUST +return only the claims that are mandatory to present (e.g., SD-JWT and Key Binding JWT for a Credential +of format IETF SD-JWT VC).¶ + + If claims is present, but claim_sets is absent, +the Verifier requests all claims listed in claims.¶ + + If both claims and claim_sets are present, the Verifier requests one combination of the claims listed in +claim_sets. The order of the options conveyed in the claim_sets +array expresses the Verifier's preference for what is returned; the Wallet SHOULD return +the first option that it can satisfy. If the Wallet cannot satisfy any of the +options, it MUST NOT return any claims.¶ + + claim_sets MUST NOT be present if claims is absent.¶ + +When a Claims Query contains a restriction on the values of a claim, the Wallet +SHOULD NOT return the claim if its value does not match according to the rules for +values defined in Section 6.3, i.e., +the claim should be treated the same as if it did not +exist in the Credential. Implementing this restriction may not be possible in +all cases, for example, if the Wallet does not have access to the claim value +before presentation or user consent or if another component routing +the request to the Wallet does not have access to the claim value. It is ultimately up to the +Wallet and/or the End-User if the value matching request +is followed. Therefore, Verifiers MUST treat restrictions expressed using values as a +best-effort way to improve user privacy, but MUST NOT rely on it for security checks.¶ + +The purpose of the claim_sets syntax is to provide a way for a Verifier to +describe alternative ways a given Credential can satisfy the request. The array +ordering expresses the Verifier's preference for how to fulfill the request. The +first element in the array is the most preferred and the last element in the +array is the least preferred. Verifiers SHOULD use the principle of least +information disclosure to influence how they order these options. For example, a +proof of age request should prioritize requesting an attribute like +age_over_18 over an attribute like birth_date. The claim_sets syntax is +not intended to define options the End-User can choose from, see Section 6.4.3 for +more information. The Wallet is recommended to return the first option it can satisfy +since that is the preferred option from the Verifier. However, there can be reasons to +deviate. Non-exhaustive examples of such reasons are:¶ + +scenarios where the Verifier did not order the options to minimize information disclosure¶ + + operational reasons why returning a different option than the first option has UX benefits for the Wallet.¶ + +If the Wallet cannot deliver all claims requested by the Verifier +according to these rules, it MUST NOT return the respective Credential.¶ + +For Credential Formats that do not support selective disclosure, the case of both claims +and claim_sets being absent is interpreted as requesting a presentation of the "full credential" +since all claims are mandatory to present.¶ + +6.4.2. Selecting Credentials + +The following rules apply for selecting Credentials via credentials and credential_sets:¶ + +If credential_sets is not provided, the Verifier requests presentations for all +Credentials in credentials to be returned.¶ + + Otherwise, the Verifier requests presentations of Credentials to be returned satisfying¶ + +all of the Credential Set Queries in the credential_sets array where the required attribute is true or omitted, and¶ + + optionally, any of the other Credential Set Queries.¶ + +To satisfy a Credential Set Query, the Wallet MUST return presentations of a +set of Credentials that match to one of the options inside the +Credential Set Query.¶ + +Credentials not matching the respective constraints expressed within +credentials MUST NOT be returned, i.e., they are treated as if +they would not exist in the Wallet.¶ + +If the Wallet cannot deliver all non-optional Credentials requested by the +Verifier according to these rules, it MUST NOT return any Credential(s).¶ + +6.4.3. User Interface Considerations + +While this specification provides the mechanisms for requesting different sets +of claims and Credentials, it does not define details about the user interface +of the Wallet, for example, if and how End-Users can select which combination of +Credentials to present. However, it is typically expected that the Wallet +presents the End-User with a choice of which Credential(s) to present if +multiple of the sets of Credentials in options can satisfy the request.¶ + +7. Claims Path Pointer + +A claims path pointer is a pointer into the Credential, identifying one or more claims. +A claims path pointer MUST be a non-empty array of strings, nulls and non-negative integers. +A claims path pointer can be processed, which means it is applied to a Credential. The results of +processing are the referenced claims.¶ + +7.1. Semantics for JSON-based credentials + +This section defines the semantics of a claims path pointer when applied to a JSON-based Credential.¶ + +A string value indicates that the respective key is to be selected, a null value +indicates that all elements of the currently selected array(s) are to be selected; +and a non-negative integer indicates that the respective index in an array is to be selected. The path +is formed as follows:¶ + +Start with an empty array and repeat the following until the full path is formed.¶ + +To address a particular claim within an object, append the key (claim name) +to the array.¶ + + To address an element within an array, append the index to the array (as a +non-negative, 0-based integer).¶ + + To address all elements within an array, append a null value to the array.¶ + +7.1.1. Processing + +In detail, the array is processed from left to right as follows:¶ + +Select the root element of the Credential, i.e., the top-level JSON object.¶ + + Process the query of the claims path pointer array from left to right:¶ + +If the component is a string, select the element in the respective +key in the currently selected element(s). If any of the currently +selected element(s) is not an object, abort processing and return an +error. If the key does not exist in any element currently selected, +remove that element from the selection.¶ + + If the component is null, select all elements of the currently +selected array(s). If any of the currently selected element(s) is not an +array, abort processing and return an error.¶ + + If the component is a non-negative integer, select the element at +the respective index in the currently selected array(s). If any of the +currently selected element(s) is not an array, abort processing and +return an error. If the index does not exist in a selected array, remove +that array from the selection.¶ + + If the component is anything else, abort processing and return an error.¶ + + If the set of elements currently selected is empty, abort processing and +return an error.¶ + +The result of the processing is the set of selected JSON elements.¶ + +7.2. Semantics for ISO mdoc-based credentials + +This section defines the semantics of a claims path pointer when applied to a +credential in ISO mdoc format.¶ + +A claims path pointer into an mdoc contains two elements of type string. The +first element refers to a namespace and the second element refers to a data +element identifier.¶ + +7.2.1. Processing + +In detail, the array is processed as follows:¶ + +If the claims path pointer does not contain exactly two components or +one of the components is not a string then abort processing and return an error.¶ + + Select the namespace referenced by the first component. If the namespace does +not exist in the mdoc then abort processing and return an error.¶ + + Select the data element referenced by the second component. If the data element does not exist +in the Credential then abort processing and return an error.¶ + +The result of the processing is the selected data element value as CBOR data item.¶ + +7.3. Claims Path Pointer Example + +The following shows a non-normative, simplified example of a JSON-based Credential:¶ + +{ + "name": "Arthur Dent", + "address": { + "street_address": "42 Market Street", + "locality": "Milliways", + "postal_code": "12345" + }, + "degrees": [ + { + "type": "Bachelor of Science", + "university": "University of Betelgeuse" + }, + { + "type": "Master of Science", + "university": "University of Betelgeuse" + } + ], + "nationalities": ["British", "Betelgeusian"] +} +¶ + +The following shows examples of claims path pointers and the respective selected +claims:¶ + + ["name"]: The claim name with the value Arthur Dent is selected.¶ + + ["address"]: The claim address with its sub-claims as the value is +selected.¶ + + ["address", "street_address"]: The claim street_address with the value 42 +Market Street is selected.¶ + + ["degrees", null, "type"]: All type claims in the degrees array are +selected.¶ + + ["nationalities", 1]: The second nationality is selected.¶ + +7.4. DCQL Examples + +The following is a non-normative example of a DCQL query that requests a +Credential of the format dc+sd-jwt with a type value of +https://credentials.example.com/identity_credential and the claims last_name, +first_name, and address.street_address:¶ + +{ + "credentials": [ + { + "id": "my_credential", + "format": "dc+sd-jwt", + "meta": { + "vct_values": [ "https://credentials.example.com/identity_credential" ] + }, + "claims": [ + {"path": ["last_name"]}, + {"path": ["first_name"]}, + {"path": ["address", "street_address"]} + ] + } + ] +} +¶ + +Additional, more complex examples can be found in Appendix D.¶ + +8. Response + +A VP Token is only returned if the corresponding Authorization Request contained a dcql_query parameter or a scope parameter representing a DCQL Query Section 5.¶ + +A VP Token can be returned in the Authorization Response or the Token Response depending on the Response Type used. See Section 5.6 for more details.¶ + +If the Response Type value is vp_token, the VP Token is returned in the Authorization Response. When the Response Type value is vp_token id_token and the scope parameter contains openid, the VP Token is returned in the Authorization Response alongside a Self-Issued ID Token as defined in [SIOPv2].¶ + +If the Response Type value is code (Authorization Code Grant Type), the VP Token is provided in the Token Response.¶ + +The expected behavior is summarized in the following table:¶ + +Table 1: +OpenID for Verifiable Presentations response_type values + + response_type parameter value + Response containing the VP Token + + vp_token + + Authorization Response + + vp_token id_token + + Authorization Response + + code + + Token Response + +The behavior with respect to the VP Token is unspecified for any other individual Response Type value, or a combination of Response Type values.¶ + +8.1. Response Parameters + +When a VP Token is returned, the respective response includes the following parameters:¶ + +vp_token: + REQUIRED. This is a JSON-encoded object containing entries where the key is the id value used for a Credential Query in the DCQL query and the value is an array of one or more Presentations that match the respective Credential Query. When multiple is omitted, or set to false, the array MUST contain only one Presentation. There MUST NOT be any entry in the JSON-encoded object for optional Credential Queries when there are no matching Credentials for the respective Credential Query. Each Presentation is represented as a string or object, depending on the format as defined in Appendix B. The same rules as above apply for encoding the Presentations.¶ + +Other parameters, such as code (from [RFC6749]), or id_token (from [OpenID.Core]), and iss (from [RFC9207]) can be included in the response as defined in the respective specifications.¶ + +Additional response parameters MAY be defined and used, +as described in [RFC6749]. +The Client MUST ignore any unrecognized parameters.¶ + +The following is a non-normative example of an Authorization Response when the Response Type value in the Authorization Request was vp_token:¶ + +HTTP/1.1 302 Found +Location: https://client.example.org/cb# + vp_token=... +¶ + +8.1.1. Examples + +The following is a non-normative example of the contents of a VP Token +containing a single Verifiable Presentation in the SD-JWT VC format after a +request using DCQL like the one shown in Section 7.4 (shortened for +brevity):¶ + +{ + "my_credential": ["eyJhbGci...QMA"] +} +¶ + +The following is a non-normative example of the contents of a VP Token +containing multiple Verifiable Presentations in the SD-JWT VC format when the +Credential Query has multiple set to true (shortened for brevity):¶ + +{ + "my_credential": ["eyJhbGci...QMA", "eyJhbGci...QMA", ...] +} +¶ + +8.2. Response Mode "direct_post" + +The Response Mode direct_post allows the Wallet to send the Authorization Response to an endpoint controlled by the Verifier via an HTTP POST request.¶ + +It has been defined to address the following use cases:¶ + +Verifier and Wallet are located on different devices; thus, the Wallet cannot send the Authorization Response to the Verifier using a redirect.¶ + + The Authorization Response size exceeds the URL length limits of user agents, so flows relying only on redirects (such as Response Mode fragment) cannot be used. In those cases, the Response Mode direct_post is the way to convey the Presentations to the Verifier without the need for the Wallet to have a backend.¶ + +The Response Mode is defined in accordance with [OAuth.Responses] as follows:¶ + +direct_post: + In this mode, the Authorization Response is sent to the Verifier using an HTTP POST request to an endpoint controlled by the Verifier. The Authorization Response MUST be encoded in the request body using the format defined by the application/x-www-form-urlencoded HTTP content type. The parameters in the request body MUST all be encoded using UTF-8. The Verifier can request that the Wallet redirects the End-User to the Verifier using the response as defined below.¶ + +The following new Authorization Request parameter is defined to be used in conjunction with Response Mode direct_post:¶ + +response_uri: + REQUIRED when the Response Mode direct_post is used. The URL to which the Wallet MUST send the Authorization Response using an HTTP POST request as defined by the Response Mode direct_post. The Response URI receives all Authorization Response parameters as defined by the respective Response Type. When the response_uri parameter is present, the redirect_uri Authorization Request parameter MUST NOT be present. If the redirect_uri Authorization Request parameter is present when the Response Mode is direct_post, the Wallet MUST return an invalid_request Authorization Response error. The response_uri value MUST be a value that the client would be permitted to use as redirect_uri when following the rules defined in Section 5.9.¶ + +Note: When the specification text refers to the usage of Redirect URI in the Authorization Request, that part of the text also applies when Response URI is used in the Authorization Request with Response Mode direct_post.¶ + +Note: The Verifier's component providing the user interface (Frontend) and the Verifier's component providing the Response URI need to be able to map authorization requests to the respective authorization responses. The Verifier MAY use the state Authorization Request parameter to add appropriate data to the Authorization Response for that purpose, for details see Section 13.3.¶ + +Additional request parameters MAY be defined and used with the Response Mode direct_post. +The Wallet MUST ignore any unrecognized parameters.¶ + +The following is a non-normative example of the payload of a Request Object with Response Mode direct_post:¶ + +{ + "client_id": "redirect_uri:https://client.example.org/post", + "response_uri": "https://client.example.org/post", + "response_type": "vp_token", + "response_mode": "direct_post", + "dcql_query": {...}, + "nonce": "n-0S6_WzA2Mj", + "state": "eyJhb...6-sVA" +} +¶ + +The following non-normative example of an Authorization Request refers to the Authorization Request Object from above through the request_uri parameter. The Authorization Request can be displayed to the End-User either directly (as a link) or as a QR Code:¶ + +https://wallet.example.com? + client_id=https%3A%2F%2Fclient.example.org%2Fcb + &request_uri=https%3A%2F%2Fclient.example.org%2F567545564 +¶ + +The following is a non-normative example of the Authorization Response that is sent via an HTTP POST request to the Verifier's Response URI:¶ + +POST /post HTTP/1.1 +Host: client.example.org +Content-Type: application/x-www-form-urlencoded + + vp_token=...& + state=eyJhb...6-sVA +¶ + +The following is a non-normative example of an Authorization Error Response that is sent as an HTTP POST request to the Verifier's Response URI:¶ + +POST /post HTTP/1.1 +Host: client.example.org +Content-Type: application/x-www-form-urlencoded + + error=invalid_request& + error_description=unsupported%20client_id_prefix& + state=eyJhb...6-sVA +¶ + +If the Response URI has successfully processed the Authorization Response or Authorization Error Response, it MUST respond with an HTTP status code of 200 with Content-Type of application/json and a JSON object in the response body.¶ + +The following new parameter is defined for use in the JSON object returned from the Response Endpoint to the Wallet:¶ + +redirect_uri: + OPTIONAL. String containing a URI. When this parameter is present the Wallet MUST redirect the user agent to this URI. This allows the Verifier to continue the interaction with the End-User on the device where the Wallet resides after the Wallet has sent the Authorization Response to the Response URI. It can be used by the Verifier to prevent session fixation (Section 14.2) attacks. The Response URI MAY return the redirect_uri parameter in response to successful Authorization Responses or for Error Responses.¶ + +Additional response parameters MAY be defined and used. The Wallet MUST ignore any unrecognized parameters.¶ + +Note: Response Mode direct_post without the redirect_uri could be less secure than Response Modes with redirects. For details, see (Section 14.2).¶ + +The value of the redirect URI is an absolute URI as defined by [RFC3986] Section 4.3 and is chosen by the Verifier. The Verifier MUST include a fresh, cryptographically random value in the URL. This value is used to ensure only the receiver of the redirect can fetch and process the Authorization Response. The value can be added as a path component, as a fragment or as a parameter to the URL. It is RECOMMENDED to use a cryptographic random value of 128 bits or more. For implementation considerations see Section 13.3.¶ + +The following is a non-normative example of the response from the Verifier to the Wallet upon receiving the Authorization Response at the Response URI (using a response_code parameter from Section 13.3):¶ + +HTTP/1.1 200 OK +Content-Type: application/json +Cache-Control: no-store + +{ + "redirect_uri": "https://client.example.org/cb#response_code=091535f699ea575c7937fa5f0f454aee" +} +¶ + +If the response does not contain the redirect_uri parameter, the Wallet is not required to perform any further steps.¶ + +Note: In the Response Mode direct_post or direct_post.jwt, the Wallet can change the UI based on the Verifier's callback to the Wallet following the submission of the Authorization Response.¶ + +Additional parameters MAY be defined and used in the response from the Response Endpoint to the Wallet. +The Wallet MUST ignore any unrecognized parameters.¶ + +8.3. Encrypted Responses + +This section defines how an Authorization Response containing a VP Token (such as when the Response Type value is vp_token or vp_token id_token) can be encrypted at the application level using [RFC7518] where the payload of the JWE is a JSON object containing the Authorization Response parameters. Encrypting the Authorization Response can, for example, prevent personal data in the Authorization Response from leaking, when the Authorization Response is returned through the front channel (e.g., the browser).¶ + +To encrypt the Authorization Response, implementations MUST use an unsigned, encrypted JWT as described in [RFC7519].¶ + +To obtain the Verifier's public key to which to encrypt the Authorization Response, the Wallet uses JWKs from client metadata (such as the jwks member within the client_metadata request parameter or other mechanisms as allowed by the given Client Identifier Prefix). +Using what it supports and its preferences, the Wallet selects the public key to encrypt the Authorization Response based on information about each key, such as the kty (Key Type), use (Public Key Use), alg (Algorithm), and other JWK parameters. +The alg parameter MUST be present in the JWKs. +The JWE alg algorithm used MUST be equal to the alg value of the chosen jwk. +If the selected public key contains a kid parameter, the JWE MUST include the same value in the kid JWE Header Parameter (as defined in Section 4.1.6) of the encrypted response. This enables the Verifier to easily identify the specific public key that was used to encrypt the response. +The JWE enc content encryption algorithm used is obtained from the encrypted_response_enc_values_supported parameter of client metadata, such as the client_metadata request parameter, allowing for the default value of A128GCM when not explicitly set.¶ + +The payload of the encrypted JWT response MUST include the contents of the response as defined in Section 8.1 as top-level JSON members.¶ + +The following shows a non-normative example of the content of a request that is asking for an encrypted response while providing +a few public keys for encryption in the jwks member of the client_metadata request parameter:¶ + +{ + "response_type": "vp_token", + "response_mode": "dc_api.jwt", + "nonce": "xyz123ltcaccescbwc777", + "dcql_query": { + "credentials": [ + { + "id": "my_credential", + "format": "dc+sd-jwt", + "meta": { + "vct_values": ["https://credentials.example.com/identity_credential"] + }, + "claims": [ + {"path": ["last_name"]}, + {"path": ["first_name"]}, + {"path": ["address", "postal_code"]} + ] + } + ] + }, + "client_metadata": { + "jwks": { + "keys": [ + { + "kty":"EC", "kid":"ac", "use":"enc", "crv":"P-256","alg":"ECDH-ES", + "x":"YO4epjifD-KWeq1sL2tNmm36BhXnkJ0He-WqMYrp9Fk", + "y":"Hekpm0zfK7C-YccH5iBjcIXgf6YdUvNUac_0At55Okk" + }, + { + "kty":"OKP","kid":"jc","use":"enc","crv":"X25519","alg":"ECDH-ES", + "x":"WPX7wnwq10hFNK9aDSyG1QlLswE_CJY14LdhcFUIVVc" + }, + { + "kty":"EC","kid":"lc","use":"enc","crv":"P-384","alg":"ECDH-ES", + "x":"iHytgLNtXjEyYMAIGwfgjINZRmLfObYbmjPhkaPD8OiTkJtRHjegTNdH31Mxg4nV", + "y":"MizXWSqNB7sSt_SNjg3spvaJnmjB-LpxsPpLUaea33rvINL3Mq-gEaANErRQpbLx" + }, + { + "kty":"OKP","kid":"bc","use":"enc","crv":"X448","alg":"ECDH-ES", + "x":"pK5IRpLlX-8XcsRYWHejpzkfsHoDOmAYuBzAC7aTpewWOw_QFHSa64t9p2kuommI8JQQLohS2AIA" + } + ] + }, + "encrypted_response_enc_values_supported": ["A128GCM", "A128CBC-HS256"] + } +} +¶ + +A non-normative example response to the above request, having been encrypted to the first key, might look like the following +(with added line breaks for display purposes only):¶ + +{ + "response" : "eyJhbGciOiJFQ0RILUVTIiwiZW5jIjoiQTEyOEdDTSIsImtpZCI6ImFjIiwiZXBrIjp7Imt + 0eSI6IkVDIiwieCI6Im5ubVZwbTNWM2piaGNhZlFhUkJrU1ZOSGx3Wkh3dC05ck9wSnVmeVlJdWsiLCJ5I + joicjRmakRxd0p5czlxVU9QLV9iM21SNVNaRy0tQ3dPMm1pYzVWU05UWU45ZyIsImNydiI6IlAtMjU2In1 + 9..uAYcHRUSSn2X0WPX.yVzlGSYG4qbg0bq18JcUiDRw56yVnbKR8E7S7YlEtzT00RqE3Pw5oTpUG3hdLN + 4taHZ9gC1kwak8JOnJgQ.1wR024_3-qtAlx1oFIUpQQ" +} +¶ + +For illustrative purposes, the following JWK includes the private key d parameter value and can be used to decrypt the above encrypted Authorization Response example.¶ + +{ + "kty":"EC", "kid":"ac", "use":"enc", "crv":"P-256","alg":"ECDH-ES", + "x":"YO4epjifD-KWeq1sL2tNmm36BhXnkJ0He-WqMYrp9Fk", + "y":"Hekpm0zfK7C-YccH5iBjcIXgf6YdUvNUac_0At55Okk", + "d":"Et-3ce0omz8_TuZ96Df9lp0GAaaDoUnDe6X-CRO7Aww" +} +¶ + +The following shows the decoded header of the above encrypted Authorization Response example:¶ + +{ + "alg": "ECDH-ES", + "enc": "A128GCM", + "kid": "ac", + "epk": { + "kty": "EC", + "x": "nnmVpm3V3jbhcafQaRBkSVNHlwZHwt-9rOpJufyYIuk", + "y": "r4fjDqwJys9qUOP-_b3mR5SZG--CwO2mic5VSNTYN9g", + "crv": "P-256" + } +} +¶ + +While this shows the payload of the above encrypted Authorization Response example:¶ + +{ + "vp_token": {"example_credential_id": ["eyJhb...YMetA"]} +} +¶ + +Note that for the ECDH JWE algorithms (from Section 4.6 of [RFC7518]), the apu and apv values are inputs +into the key derivation process that is used to derive the content encryption key. Regardless of the algorithm used, the values are always part of the AEAD tag computation so will still be bound to the encrypted response.¶ + +Note: For encryption, implementers have a variety of options available through JOSE, including the use of Hybrid Public Key Encryption (HPKE) as detailed in [I-D.ietf-jose-hpke-encrypt].¶ + +8.3.1. Response Mode "direct_post.jwt" + +This specification also defines a new Response Mode direct_post.jwt, which allows for encryption to be used on top of the Response Mode direct_post defined in Section 8.2. The mechanisms described in Section 8.2 apply unless specified otherwise in this section.¶ + +The Response Mode direct_post.jwt causes the Wallet to send the Authorization Response using an HTTP POST request instead of redirecting back to the Verifier as defined in Section 8.2. The Wallet adds the response parameter containing the JWT as defined in Section 8.3 in the body of an HTTP POST request using the application/x-www-form-urlencoded content type. The names and values in the body MUST be encoded using UTF-8.¶ + +If a Wallet is unable to generate an encrypted response, it MAY send an error response without encryption as per Section 8.2.¶ + +The following is a non-normative example of a response (omitted content shown with ellipses for display purposes only):¶ + +POST /post HTTP/1.1 +Host: client.example.org +Content-Type: application/x-www-form-urlencoded + +response=eyJra...9t2LQ +¶ + +The following is a non-normative example of the payload of the JWT used in the example above before encrypting and base64url encoding (omitted content shown with ellipses for display purposes only):¶ + +{ + "vp_token": {"example_jwt_vc": ["eY...QMA"]} +} +¶ + +8.4. Transaction Data + +The transaction data mechanism enables a binding between the user's identification/authentication and the user’s authorization, for example to complete a payment transaction, or to sign specific document(s) using QES (Qualified Electronic Signatures). This is achieved by signing the transaction data used for user authorization with the user-controlled key used for proof of possession of the Credential being presented as a means for user identification/authentication.¶ + +The Wallet that received the transaction_data parameter in the request MUST include a representation or reference to the data in the respective Credential presentation. How this is done is transaction data type specific. Credential Formats can give recommendations of how to handle transaction data, such as those in Appendix B.¶ + +If the Wallet does not support transaction_data parameter, it MUST return an error upon receiving a request that includes it.¶ + +8.5. Error Response + +The error response follows the rules as defined in [RFC6749], with the following additional clarifications:¶ + +invalid_scope:¶ + +Requested scope value is invalid, unknown, or malformed.¶ + +invalid_request:¶ + +The request contains both a dcql_query parameter and a scope parameter referencing a DCQL query.¶ + + The request uses the vp_token Response Type but does not include a dcql_query parameter nor a scope parameter referencing a DCQL query.¶ + + The Wallet does not support the Client Identifier Prefix passed in the Authorization Request.¶ + + The Client Identifier passed in the request did not belong to its Client Identifier Prefix, or requirements of a certain prefix were violated, for example an unsigned request was sent with Client Identifier Prefix https.¶ + +invalid_client:¶ + + client_metadata parameter defined in Section 5.1 is present, but the Wallet recognizes Client Identifier and knows metadata associated with it.¶ + + Verifier's pre-registered metadata has been found based on the Client Identifier, but client_metadata parameter is also present.¶ + +access_denied:¶ + +The Wallet did not have the requested Credentials to satisfy the Authorization Request.¶ + + The End-User did not give consent to share the requested Credentials with the Verifier.¶ + + The Wallet failed to authenticate the End-User.¶ + +This document also defines the following additional error codes and error descriptions:¶ + +vp_formats_not_supported:¶ + +The Wallet does not support any of the formats requested by the Verifier, such as those included in the vp_formats_supported registration parameter.¶ + +invalid_request_uri_method:¶ + +The value of the request_uri_method request parameter is neither get nor post (case-sensitive).¶ + +invalid_transaction_data:¶ + + any of the following is true for at least one object in the transaction_data structure:¶ + +contains an unknown or unsupported transaction data type value,¶ + + is an object of a known type but containing unknown fields,¶ + + contains fields of the wrong type for the transaction data type,¶ + + contains fields with invalid values for the transaction data type,¶ + + is missing required fields for the transaction data type,¶ + + the credential_ids does not match, or¶ + + the referenced Credential(s) are not available in the Wallet.¶ + +wallet_unavailable:¶ + +The Wallet appears to be unavailable and therefore unable to respond to the request. It can be useful in situations where the user agent cannot invoke the Wallet and another component receives the request while the End-User wishes to continue the journey on the Verifier website. For example, this applies when using claimed HTTPS URIs handled by the Wallet provider in case the platform cannot or does not translate the URI into a platform intent to invoke the Wallet. In this case, the Wallet provider would return the Authorization Error Response to the Verifier and might redirect the user agent back to the Verifier website.¶ + +8.6. VP Token Validation + +Verifiers MUST validate the VP Token in the following manner:¶ + +Validate the format of the VP Token as defined in Section 8.1.¶ + + Check the individual Presentations according to the specific Credential Format requested:¶ + +Validate the integrity and authenticity of the Presentation and Credential.¶ + + Validate that the returned Credential(s) meet all criteria defined in the query in the Authorization Request (e.g., Claims included in the presentation).¶ + + Validate that all Presentations contain a cryptographic proof of Holder Binding (i.e., that they are Verifiable Presentations), unless specifically requested otherwise.¶ + + For Verifiable Presentations, validate the Holder Binding, including the checks required to prevent replay described in Section 14.1.¶ + + Perform the checks required by the Verifier's policy based on the set of trust requirements such as trust frameworks it belongs to (e.g., revocation checks), if applicable.¶ + + Check that the set of Presentations returned satisfies all requirements defined in the Verifier's request as described in Section 6.4.¶ + +If any of the checks related to an individual Presentation fail, the effected Presentation MUST be discarded. If any of the checks pertaining to the VP Token or the overall response fails, the VP Token MUST be rejected.¶ + +9. Wallet Invocation + +The Verifier can use one of the following mechanisms to invoke a Wallet:¶ + +Custom URL scheme as an authorization_endpoint (for example, openid4vp:// as defined in Section 13.1.2)¶ + + URL (including Domain-bound Universal Links/App link) as an authorization_endpoint¶ + +For a cross device flow, either of the above options MAY be presented as a QR code for the End-User to scan using a Wallet or an arbitrary camera application on a user-device.¶ + +The Wallet can also be invoked from the web or a native app using the Digital Credentials API as described in Appendix A. As described in detail in Appendix A, DC API provides privacy, security (see Section 14.2), and user experience benefits (particularly in the cases where an End-User has multiple Wallets).¶ + +10. Wallet Metadata (Authorization Server Metadata) + +This specification defines how the Verifier can determine Credential formats, proof types and algorithms supported by the Wallet to be used in a protocol exchange.¶ + +10.1. Additional Wallet Metadata Parameters + +This specification defines new metadata parameters according to [RFC8414].¶ + +vp_formats_supported: + + REQUIRED. An object containing a list of name/value pairs, where the name is a Credential Format Identifier and the value defines format-specific parameters that a Wallet supports. For specific values that can be used, see Appendix B. +Deployments can extend the formats supported, provided Issuers, Holders and Verifiers all understand the new format.¶ + +The following is a non-normative example of a vp_formats_supported parameter:¶ + +"vp_formats_supported": { + "jwt_vc_json": { + "alg_values": [ + "ES256K", + "ES384" + ] + } +} +¶ + +client_id_prefixes_supported: + + OPTIONAL. A non-empty array of strings containing the values of the Client Identifier Prefixes that the Wallet supports. The values defined by this specification are pre-registered (which represents the behavior when no Client Identifier Prefix is used), redirect_uri, openid_federation, verifier_attestation, decentralized_identifier, x509_san_dns and x509_hash. If omitted, the default value is pre-registered. Other values may be used when defined in the profiles or extensions of this specification.¶ + +Additional Wallet metadata parameters MAY be defined and used, +as described in [RFC8414]. +The Verifier MUST ignore any unrecognized parameters.¶ + +10.2. Obtaining Wallet's Metadata + +A Verifier utilizing this specification has multiple options to obtain the Wallet's metadata:¶ + +Verifier obtains the Wallet's metadata dynamically, e.g., using [RFC8414] or out-of-band mechanisms. See Section 10 for the details.¶ + + Verifier has pre-obtained a static set of the Wallet's metadata. See Section 13.1.2 for the example.¶ + +11. Verifier Metadata (Client Metadata) + +To convey Verifier metadata, Client metadata defined in Section 2 of [RFC7591] is used.¶ + +This specification defines how the Wallet can determine Credential formats, proof types and algorithms supported by the Verifier to be used in a protocol exchange.¶ + +11.1. Additional Verifier Metadata Parameters + +This specification defines the following new Client metadata parameters according to [RFC7591], to be used by the Verifier:¶ + +vp_formats_supported: + REQUIRED. An object containing a list of name/value pairs, where the name is a Credential Format Identifier and the value defines format-specific parameters that a Verifier supports. For specific values that can be used, see Appendix B. +Deployments can extend the formats supported, provided Issuers, Holders and Verifiers all understand the new format.¶ + +Additional Verifier metadata parameters MAY be defined and used, +as described in [RFC7591]. +The Wallet MUST ignore any unrecognized parameters.¶ + +12. Verifier Attestation JWT + +The Verifier Attestation JWT is a JWT especially designed to allow a Wallet to authenticate a Verifier in a secure and flexible manner. A Verifier Attestation JWT is issued to the Verifier by a party that Wallets trust for the purpose of authentication and authorization of Verifiers. The way this trust is established is out of scope of this specification. Every Verifier is bound to a public key, the Verifier MUST always present a Verifier Attestation JWT along with the proof of possession for this key. In the case of the Client Identifier Prefix verifier_attestation, the authorization request is signed with this key, which serves as proof of possession.¶ + +A Verifier Attestation JWT MUST contain the following claims:¶ + + iss: REQUIRED. This claim identifies the issuer of the Verifier Attestation JWT. The iss value MAY be used to retrieve the issuer's public key. How the trust is established between Wallet and Issuer and how the public key is obtained for validating the attestation's signature is out of scope of this specification.¶ + + sub: REQUIRED. The value of this claim MUST be the client_id of the client making the Credential request.¶ + + iat: OPTIONAL. A number representing the time at which the Verifier Attestation JWT was issued using the syntax defined in [RFC7519].¶ + + exp: REQUIRED. A number representing the time at which the Verifier Attestation JWT expires using the syntax defined in [RFC7519]. The Wallet MUST reject any Verifier Attestation JWT with an expiration time that has passed, subject to allowable clock skew between systems.¶ + + nbf: OPTIONAL. A number representing the time before which the token MUST NOT be accepted for processing.¶ + + cnf: REQUIRED. This claim contains the confirmation method as defined in [RFC7800]. It MUST contain a JSON Web Key [RFC7517] as defined in Section 3.2 of [RFC7800]. This claim determines the public key that the Verifier MUST prove possession of the corresponding private key for when presenting the Verifier Attestation JWT. This additional security measure allows the Verifier to obtain a Verifier Attestation JWT from a trusted issuer and use it for a long time independent of that issuer without the risk of an adversary impersonating the Verifier by replaying a captured attestation.¶ + +Additional claims MAY be defined and used in the Verifier Attestation JWT, +as described in [RFC7519]. +The Wallet MUST ignore any unrecognized claims.¶ + +Verifier Attestation JWTs compliant with this specification MUST use the media type application/verifier-attestation+jwt as defined in Appendix E.6.1.¶ + +A Verifier Attestation JWT MUST set the typ JOSE header to verifier-attestation+jwt.¶ + +The Verifier Attestation JWT MAY be conveyed in the header of a JWS signed object (JOSE header).¶ + +This specification introduces a JOSE header, which can be used to add a JWT to such a header as follows:¶ + + jwt: This JOSE header MUST contain a JWT.¶ + +In the context of this specification, such a JWT MUST set the typ JOSE header to verifier-attestation+jwt.¶ + +13. Implementation Considerations + +13.1. Static Configuration Values of the Wallets + +This section lists profiles of this specification that define static configuration values for Wallets and defines one set of static configuration values that can be used by the Verifier when it is unable to perform Dynamic Discovery.¶ + +13.1.1. Profiles that Define Static Configuration Values + +The following is a list of profiles that define static configuration values of Wallets:¶ + + OpenID4VC High Assurance Interoperability Profile 1.0¶ + + JWT VC Presentation Profile¶ + +13.1.2. A Set of Static Configuration Values bound to openid4vp:// + +The following is a non-normative example of a set of static configuration values that can be used with vp_token parameter as a supported Response Type, bound to a custom URL scheme openid4vp:// as an Authorization Endpoint:¶ + +{ + "authorization_endpoint": "openid4vp:", + "response_types_supported": [ + "vp_token" + ], + "vp_formats_supported": { + "dc+sd-jwt": { + "sd-jwt_alg_values": [ + "ES256" + ], + "kb-jwt_alg_values": [ + "ES256" + ] + }, + "mso_mdoc": {} + }, + "request_object_signing_alg_values_supported": [ + "ES256" + ] +} +¶ + +13.2. Nested Presentations + +This specification does not support presentation of a Presentation nested inside another Presentation.¶ + +13.3. Response Mode direct_post + +The design of the interactions between the different components of the Verifier (especially Frontend and Response URI) when using Response Mode direct_post is at the discretion of the Verifier since it does not affect the interface between the Verifier and the Wallet.¶ + +In order to support implementers, this section outlines a possible design that fulfills the Security Considerations given in Section 14.¶ + +Figure 3 illustrates a sequence diagram of the design:¶ + ++--------+ +------------+ +---------------------+ +----------+ +|End-User| | Verifier | | Verifier | | Wallet | +| | | | | Response Endpoint | | | ++--------+ +------------+ +---------------------+ +----------+ + | | | | + | interacts | | | + |------------->| | | + | | (1) create nonce | | + | |-----------+ | | + | | | | | + | |<----------+ | | + | | | | + | | (2) initiate transaction | | + | |--------------------------->| | + | | | | + | | (3) return transaction-id & request-id | + | |<---------------------------| | + | | | | + | | (4) Authorization Request | + | | (response_uri, nonce, state, dcql_query) | + | |-------------------------------------------------------------->| + | | | | + | End-User Authentication / Consent | + | | | | + | | | (5) Authorization Response | + | | | (VP Token, state) | + | | |<---------------------------------| + | | | | + | | | (6) Response | + | | | (redirect_uri with response_code)| + | | |--------------------------------->| + | | | | + | | (7) Redirect to the redirect URI (response_code) | + | |<--------------------------------------------------------------| + | | | | + | | (8) fetch response data | | + | | (transaction-id, response_code) | + | |--------------------------->| | + | | | | + | | | | + | | (9) response data | | + | | (VP Token) | | + | |<---------------------------| | + | | | | + | | (10) check nonce | | + | |-----------+ | | + | | | | | + | |<----------+ | | + +Figure 3: +Reference Design for Response Mode direct_post + +(1) The Verifier produces a nonce value by generating at least 16 fresh, cryptographically random bytes with sufficient entropy, associates it with the session and base64url encodes it.¶ + +(2) The Verifier initiates a new transaction at its Response URI.¶ + +(3) The Response URI will set up the transaction and respond with two fresh, cryptographically random numbers with sufficient entropy designated as transaction-id and request-id. Those values are used in the process to identify the authorization response (request-id) and to ensure only the Verifier can obtain the Authorization Response data (transaction-id).¶ + +(4) The Verifier then sends the Authorization Request with the request-id as state and the nonce value created in step (1) to the Wallet.¶ + +(5) After authenticating the End-User and getting their consent to share the request Credentials, the Wallet sends the Authorization Response with the parameters vp_token and state to the response_uri of the Verifier.¶ + +(6) The Verifier's Response URI checks whether the state value is a valid request-id. If so, it stores the Authorization Response data linked to the respective transaction-id. It then creates a response_code as fresh, cryptographically random number with sufficient entropy that it also links with the respective Authorization Response data. It then returns the redirect_uri, which includes the response_code to the Wallet.¶ + +Note: If the Verifier's Response URI does not return a redirect_uri, processing at the Wallet stops at that step. The Verifier is supposed to fetch the Authorization Response without waiting for a redirect (see step 8).¶ + +(7) The Wallet sends the user agent to the Verifier (redirect_uri). The Verifier receives the Request and extracts the response_code parameter.¶ + +(8) The Verifier sends the response_code and the transaction-id from its session to the Response URI.¶ + +The Response URI uses the transaction-id to look the matching Authorization Response data up, which implicitly validates the transaction-id associated with the Verifier's session.¶ + + If an Authorization Response is found, the Response URI checks whether the response_code was associated with this Authorization Response in step (6).¶ + +Note: If the Verifier's Response URI did not return a redirect_uri in step (6), the Verifier will periodically query the Response URI with the transaction-id to obtain the Authorization Response once it becomes available.¶ + +(9) The Response URI returns the VP Token for further processing to the Verifier.¶ + +(10) The Verifier checks whether the nonce received in the Credential(s) in the VP Token in step (9) corresponds to the nonce value from the session. The Verifier then consumes the VP Token and invalidates the transaction-id, request-id and nonce in the session.¶ + +13.4. Pre-Final Specifications + +Implementers should be aware that this specification uses several specifications that are not yet final specifications. Those specifications are:¶ + +OpenID Federation 1.0 draft -43 [OpenID.Federation]¶ + + SIOPv2 draft -13 [SIOPv2]¶ + + Selective Disclosure for JWTs (SD-JWT) draft -22 [I-D.ietf-oauth-selective-disclosure-jwt]¶ + + SD-JWT-based Verifiable Credentials (SD-JWT VC) draft -09 [I-D.ietf-oauth-sd-jwt-vc]¶ + + Fully-Specified Algorithms for JOSE and COSE draft -13 [I-D.ietf-jose-fully-specified-algorithms]¶ + +While breaking changes to the specifications referenced in this specification are not expected, should they occur, OpenID4VP implementations should continue to use the specifically referenced versions above in preference to the final versions, unless updated by a profile or new version of this specification.¶ + +14. Security Considerations + +14.1. Preventing Replay of Verifiable Presentations + +An attacker could try to inject Presentations obtained from (for example) a previous Authorization Response into another Authorization Response, thus impersonating the End-User that originally presented the respective Verifiable Presentation. Holder Binding aims to prevent such attacks.¶ + +14.1.1. Presentations without Holder Binding Proofs + +By definition, Presentations without Holder Binding (see Section 5.3) do +not provide protection against replay. A Verifier that consumes Presentations without Holder Binding +accepts the risk that the Holder may have obtained the Credential from a third +party (e.g., by playing the role of a Verifier) and that the Holder may not be +the subject of the Credential.¶ + +Depending on the use case, the risk assessment of the Verifier, and external +validation measures that can be taken, this risk may be acceptable.¶ + +14.1.2. Verifiable Presentations + +For Verifiable Presentations, implementers of this specification MUST implement the controls as defined in this section to detect and prevent replay attacks.¶ + +The cryptographic proof of possession in a Verifiable Presentation MUST be bound by the Wallet to the intended audience (the Client Identifier of the Verifier) and the respective transaction (identified by the nonce parameter in the Authorization Request, as defined in Section 5.2). The Verifier MUST verify this binding.¶ + +The Wallet MUST link every Verifiable Presentation returned to the Verifier in the VP Token to the client_id and the nonce values of the respective Authentication Request.¶ + +The Verifier MUST validate every individual Verifiable Presentation in an Authorization Response and ensure that it is linked to the values of the client_id and the nonce parameter it had used for the respective Authorization Request. If any Verifiable Presentation in the response does not contain the correct nonce value, the response MUST be rejected.¶ + +The client_id is used to detect the replay of Verifiable Presentations to a party other than the one intended. This allows Verifiers to reject the Verifiable Presentation. The nonce value binds the Verifiable Presentation to a certain authentication transaction and allows the Verifier to detect injection of a Presentation in the flow, which is especially important in the flows where the Presentation is passed through the front-channel.¶ + +Note: Different formats for Verifiable Presentations and signature/proof schemes use different ways to represent the intended audience and the session binding. Some use claims to directly represent those values, others include the values into the calculation of cryptographic proofs. There are also different naming conventions across the different formats. The format of the respective presentation is defined by the Verifier in the request.¶ + +The following is a non-normative example of the payload of a Verifiable Presentation following a request with the Credential Format Identifier jwt_vc_json:¶ + +{ + "iss": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "jti": "urn:uuid:3978344f-8596-4c3a-a978-8fcaba3903c5", + "aud": "s6BhdRkqt3", + "nonce": "343s$FSFDa-", + "nbf": 1541493724, + "iat": 1541493724, + "exp": 1573029723, + "vp": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "type": ["VerifiablePresentation"], + + "verifiableCredential": [""] + } +} +¶ + +In the example above, the requested nonce value is included as the nonce and client_id as the aud value in the proof of the Verifiable Presentation.¶ + +The following is a non-normative example of a Verifiable Presentation following a request with the Credential Format Identifier ldp_vc without a proof property:¶ + +{ + "@context": [ ... ], + "type": "VerifiablePresentation", + "verifiableCredential": [ ... ], + "proof": { + "type": "DataIntegrityProof", + "cryptosuite": "ecdsa-rdfc-2019", + "created": "2018-09-14T21:19:10Z", + "proofPurpose": "authentication", + "verificationMethod": "did:example:ebfeb1f712ebc6f1c276e12ec21#keys-1", + "challenge": "343s$FSFDa-", + "domain": "x509_san_dns:client.example.org", + "proofValue": "z2iAR...3oj9Q8" + } +} +¶ + +In the example above, the requested nonce value is included as the challenge and client_id as the domain value in the proof of the Verifiable Presentation.¶ + +14.2. Session Fixation + +To perform a session fixation attack, an attacker would start the process using a Verifier on a device under their control, capture the Authorization Request, and relay it to the device of a victim. The attacker would then periodically try to conclude the process in their Verifier, which would cause the Verifier on their device to try to fetch and verify the Authorization Response.¶ + +Such an attack is impossible against flows implemented with the Response Mode fragment as the Wallet will always send the VP Token to the redirect endpoint on the same device where it resides. This means an attacker could extract a valid Authorization Request from a Verifier on their device and trick a Victim into performing the same Authorization Request on the victim's device. But there is usually no way for an attacker to get hold of the resulting VP Token.¶ + +However, the Response Mode direct_post is susceptible to such an attack as the result is sent from the Wallet out-of-band to the Verifier's Response URI.¶ + +This kind of attack can be detected if the Response Mode direct_post is used in conjunction with the redirect URI, which causes the Wallet to redirect the flow to the Verifier's frontend at the device where the transaction was concluded. The Verifier's Response URI MUST include a fresh secret (Response Code) into the redirect URI returned to the Wallet and the Verifier's Response URI MUST require the frontend to pass the respective Response Code when fetching the Authorization Response. That stops session fixation attacks as long as the attacker is unable to get access to the Response Code.¶ + +Note that this protection technique is not applicable to cross-device scenarios because the browser used by the Wallet will not have the original session. +It is also not applicable in same-device scenarios if the Wallet uses a browser different from the one used on the presentation request (e.g. device with multiple installed browsers), because the original session will also not be available there. Appendix A provides an alternative Wallet invocation method using web/app platform APIs that avoids many of these issues.¶ + +See Section 13.3 for more implementation considerations.¶ + +When using the Response Mode direct_post without the further protection provided by the redirect URI, there is no session context for the Verifier to detect session fixation attempts. It is RECOMMENDED for the Verifiers to implement mechanisms to strengthen the security of the flow. For more details on possible attacks and mitigations see [I-D.ietf-oauth-cross-device-security].¶ + +14.3. Response Mode "direct_post" + +14.3.1. Validation of the Response URI + +The Wallet MUST ensure the data in the Authorization Response cannot leak through Response URIs. When using pre-registered Response URIs, the Wallet MUST comply with best practices for redirect URI validation as defined in [RFC9700]. The Wallet MAY also rely on a Client Identifier Prefix in conjunction with Client Authentication and integrity protection of the request to establish trust in the Response URI provided by a certain Verifier.¶ + +14.3.2. Protection of the Response URI + +The Verifier SHOULD protect its Response URI from inadvertent requests by checking that the value of the received state parameter corresponds to a recent Authorization Request.¶ + +14.3.3. Protection of the Authorization Response Data + +This specification assumes that the Verifier's Response URI offers an internal interface to other components of the Verifier to obtain (and subsequently process) Authorization Response data. An attacker could try to obtain Authorization Response Data from a Verifier's Response URI by looking up this data through the internal interface. This could lead to leakage of valid Presentations containing personally identifiable information.¶ + +Implementations of this specification MUST have security mechanisms in place to prevent inadvertent requests against this internal interface. Implementation options to fulfill this requirement include:¶ + +Authentication between the different parts within the Verifier¶ + + Two cryptographically random numbers. The first being used to manage state between the Wallet and Verifier. The second being used to ensure that only a legitimate component of the Verifier can obtain the Authorization Response data.¶ + +14.4. End-User Authentication using Credentials + +Clients intending to authenticate the End-User utilizing a claim in a Credential MUST ensure this claim is stable for the End-User as well as locally unique and never reassigned within the Credential Issuer to another End-User. Such a claim MUST also only be used in combination with the Credential Issuer identifier to ensure global uniqueness and to prevent attacks where an attacker obtains the same claim from a different Credential Issuer and tries to impersonate the legitimate End-User.¶ + +14.5. Encrypting an Unsigned Response + +Because an encrypted Authorization Response has no additional integrity protection, an attacker might be able to alter Authorization Response parameters and generate a new encrypted Authorization Response for the Verifier, as encryption is performed using the public key of the Verifier (which is likely to be widely known when not ephemeral to the request/response). Note this includes injecting a new VP Token. Since the contents of the VP Token are integrity protected, tampering with the VP Token is detectable by the Verifier. For details, see Section 14.1.¶ + +14.6. TLS Requirements + +Implementations MUST follow [BCP195].¶ + +Whenever TLS is used, a TLS server certificate check MUST be performed, per [RFC6125].¶ + +14.7. Incomplete or Incorrect Implementations of the Specifications and Conformance Testing + +To achieve the full security benefits, it is important that the implementation of this specification, and the underlying specifications, are both complete and correct.¶ + +The OpenID Foundation provides tools that can be used to confirm that an implementation is correct and conformant:¶ + +https://openid.net/certification/conformance-testing-for-openid-for-verifiable-presentations/¶ + +14.8. Always Use the Full Client Identifier + +Confusing Verifiers using a Client Identifier Prefix with those using none can lead to attacks. Therefore, Wallets MUST always use the full Client Identifier, including the prefix if provided, within the context of the Wallet or its responses to identify the client. This refers in particular to places where the Client Identifier is used in [RFC6749] and in the presentation returned to the Verifier.¶ + +14.9. Security Checks on the Returned Credentials and Presentations + +While the Verifier can specify various constraints both on the claims level and +the Credential level as shown in Section 6.4, it MUST NOT rely on the Wallet to enforce +these constraints. The Wallet is not controlled by the Verifier and the Verifier +MUST perform its own security checks on the returned Credentials and +Presentations.¶ + +15. Privacy Considerations + +Many privacy considerations are specific to the Credential format and associated proof type used in a particular Presentation.¶ + +This section focuses on privacy considerations specific to the presentation protocol while also addressing cross-cutting concerns related to credential formats, Wallet behavior, and Verifier practices.¶ + +Wallet providers and Verifiers need to take into account privacy considerations in this section to mitigate the risks of +data leakage, user tracking, and other privacy harms.¶ + +15.1. User Consent + +Wallets SHOULD obtain explicit, informed consent from the End-User before releasing any Verifiable Credential or Presentation to a Verifier, or returning an error.¶ + +Transaction history and data within the Wallet SHOULD NOT be accessible to anyone other than the End-User, unless the End-User has given consent or there is another legal basis to do so.¶ + +15.2. Privacy Notice + +Wallets SHOULD make their privacy notices readily available to the End-User.¶ + +15.3. Purpose Legitimacy + +The Verifier SHOULD ensure that the purpose for collecting the information it is requesting is sufficiently specific and communicated before collection. For example, the purpose is shown to the End-User before or within the presentation request that is sent to the Wallet.¶ + +If the Wallet has indications that the Verifier is requesting data that it is not entitled to, the Wallet SHOULD warn the End-User or potentially stop processing.¶ + +15.4. Selective Disclosure + +Selective disclosure is a data minimization technique that allows for sharing only the specific information needed from +a Credential without revealing all of the claims contained in that Credential.¶ + +The DCQL helps facilitate selective disclosure by allowing the Verifier to specify the claims it is interested in, +allowing the Wallet to disclose only the claims that are relevant to the Verifier's request.¶ + +Some Credential formats support selective disclosure and a salted-hash based approach is one common approach.¶ + +15.4.1. DCQL Value Matching + +When using DCQL values to match the expected values of claims, the fact that a +claim within a certain Credential matched a value or did not match a value might +already leak information about the claim value. Therefore, Wallets MUST take +precautions against leaking information about the claim value when processing +values. This SHOULD include, in particular:¶ + +ensuring that a Verifier, in the response, cannot distinguish between the case where an End-User did +not consent to releasing the Credential and the case where the claim value did +not match the expected value, and¶ + + preventing repeated or "silent" requests leaking data to the Verifier without +the user's consent by ensuring that all requests, even if no response can be +sent by the Wallet due to a values mismatch, require some form of End-User +interaction before a response is sent.¶ + +In both cases listed here, it needs to be considered that returning an error +response can also leak information about the processing outcome of values.¶ + +15.4.2. Strictly Necessary Claims + +Verifiers SHOULD use DCQL queries that request only the minimal set of claims and Credentials needed to fulfill the specified purposes.¶ + +15.5. Verifier-to-Verifier Unlinkable Presentations + +Even when using selective disclosure to reveal limited claims from a Credential to a Verifier, there are ways in which a Presentation could be linked to another Presentation in another session or a Presentation to another Verifier. For example, with Credential formats such as SD-JWT and mdoc, the Issuer signature on a Credential or the public key a Credential is bound to, can provide a Verifier with a way to link the Credential across different Presentations or sessions. In order to avoid such linking, a Wallet can use multiple instances of a Credential, each with unique Issuer signatures and associated public keys to limit this:¶ + +a Wallet can use an issued Credential instance only once in a Presentation to a specific Verifier, before discarding the Credential, thus avoiding linking on the above basis ever occurring¶ + + a Wallet can apply a limited use policy for a specific instance of a Credential, perhaps only allowing it to be presented to the same Verifier to avoid Verifier to Verifier linkability¶ + +Considerable discourse regarding unlinkability in salted-hash based selective disclosure mechanisms is provided in Section 10.1 of [I-D.ietf-oauth-selective-disclosure-jwt]. One technique mentioned to achieve some important unlinkability properties is the use of batch issuance, which is supported in [OpenID4VCI], with individual Credentials being presented only once.¶ + +15.6. No Fingerprinting of the End-User + +A Verifier SHOULD NOT attempt to fingerprint the End-User based on metadata that may be available in the interaction with the End-User's wallet.¶ + +A Wallet SHOULD implement measures that prevent fingerprinting of the End-Users during the request to resolve the Request Object URI.¶ + +A Wallet SHOULD implement measures that limit unintended additional information being disclosed through the Response URI. For example, disclosing Wallet-related information through the HTTP user agent header.¶ + +15.7. Information Security + +Both Wallet providers and Verifiers SHOULD apply suitable security controls at the operational, functional, and strategic level to ensure the integrity, confidentiality and general handling of PII. Furthermore, they should consider protections against risks such as unauthorized access, destruction, use, modification, disclosure or loss throughout the whole of its life cycle.¶ + +15.8. Wallet to Verifier Communication + +Wallets SHOULD only send the minimal amount of information possible, and in particular, avoid sending any additional HTTP headers identifying the software used for the request (e.g., HTTP libraries or their versions) when retrieving a request_uri or sending to response_uri to reduce the risk of fingerprinting and End-User tracking.¶ + +Wallets MUST NOT include any personally identifiable information (PII) in HTTP requests to Verifiers unless explicitly required for the flow and authorized by the End-User.¶ + +15.8.1. Establishing Trust in the Request URI + +Wallets operating within a trust framework SHOULD validate that the Request URI is properly associated with the Client Identifier and authorized for the request.¶ + +Untrusted or unrecognized Request URI endpoints SHOULD be rejected or require End-User confirmation before proceeding.¶ + +15.8.2. Authorization Requests with Request URI + +If the Wallet is acting within a trust framework that allows the Wallet to determine whether a Request URI belongs to a certain Client Identifier, the Wallet is RECOMMENDED to validate the Verifier's authenticity and authorization given by the Client Identifier and that the Request URI corresponds to this Verifier. If the link cannot be established in those cases, the Wallet MUST refuse the request.¶ + +15.9. Error Responses + +Error responses SHOULD avoid including sensitive or detailed contextual information that could be used to infer the End-User's data.¶ + +15.9.1. wallet_unavailable Authorization Error Response + +In the event that another component is invoked instead of the Wallet, the End-User SHOULD be informed and give consent before the invoked component returns the wallet_unavailable Authorization Error Response to the Verifier.¶ + +15.9.2. Digital Credential API Error Responses + +Returning any OpenID4VP protocol error, regardless of content, can reveal additional information about the End-User’s underlying Credentials or Wallet in a way that is unique to the Digital Credentials API since reaching the Wallet can be dependent on a Wallet's ability to satisfy the request. For example, platform implementations could only allow Wallets to be selected that satisfy the request. In this case, OpenID4VP protocol error responses can only be returned by a selected Wallet and would therefore reveal that the End-User is in possession of Credentials that satisfy the request. This is in contrast to other engagement methods, in which the Wallet receives the request before learning if it can be fulfilled. What is revealed by a Wallet in those cases depends on how each individual Wallet processes the request.¶ + +The narrower a request is, the more information is revealed:¶ + +A request that can be fulfilled by a broad range of documents will only reveal that the End-User has a Credential from a large set of documents.¶ + + A request for a single document type will reveal the End-User is in possession of that Credential. How sensitive this is would depend on the particular Credential.¶ + + A request with which can only be satisfied by a single trusted authority will reveal that the End-User has a Credential from a particular authority, from which other attributes may be inferred.¶ + + A request with value matching (as defined in Section 6.4.1) will reveal the specific value of that claim/attribute.¶ + +Wallet implementations need to balance the value of error detection to the maintenance and scaling of the Verifier ecosystem with the information that is revealed.¶ + +A Wallet SHOULD NOT return any OpenID4VP protocol errors without End-User interaction either with the platform or the Wallet. When handling errors, implementations can opt to cancel the flow (the details of which are platform specific) rather than return an OpenID4VP protocol-specific error. This will make the result indistinguishable from other platform aborts, preventing any information from being revealed.¶ + +A Wallet SHOULD NOT return any OpenID4VP protocol errors before obtaining End-User consent, when processing a request containing value matching (to avoid revealing values of claims without consent), or issuer selection (to avoid revealing that the End-User has a Credential from a particular authority). Additionally, the End-User consent protects against undetected, repeated requests to the Wallet.¶ + +15.10. Establishing Trust in the Issuers + +This specification introduces an extension point that allows for a Verifier to express expected Issuers or trust frameworks that certify Issuers. It is important to understand the implications of these trust establishment mechanisms on the privacy of the overall system.¶ + +In general, two types of mechanisms can be distinguished: those that are self-contained, where the Wallet and Verifier already have all the information needed to check if a Credential satisfies the request, and those that depend on online resolution to obtain additional data. +Mechanisms that require online resolution can leak information that could be used to profile the usage of the Credentials.¶ + +In particular, situations where the Wallet must fetch data before it can generate a matching presentation may expose information about individual End-Users to external parties.¶ + +Wallets SHOULD NOT access URLs included in a request from the Verifier if those URLs are unfamiliar or hosted by untrusted third parties. Privacy risks can be reduced if such URLs are treated purely as identifiers and not actually retrieved by the Wallet upon receiving the request.¶ + +Ecosystems intending to use trusted authority mechanisms SHOULD ensure that the privacy characteristics of their chosen mechanisms align with the overall privacy goals of the ecosystem.¶ + +16. Normative References + +[BCP195] + +IETF, "BCP195", 2022, . + +[DID-Core] + +Sporny, M., Guy, A., Sabadello, M., and D. Reed, "Decentralized Identifiers (DIDs) v1.0", 19 July 2022, . + +[I-D.ietf-jose-fully-specified-algorithms] + +Jones, M. B. and O. Steele, "Fully-Specified Algorithms for JOSE and COSE", Work in Progress, Internet-Draft, draft-ietf-jose-fully-specified-algorithms-13, 11 May 2025, . + +[I-D.ietf-oauth-sd-jwt-vc] + +Terbu, O., Fett, D., and B. Campbell, "SD-JWT-based Verifiable Credentials (SD-JWT VC)", Work in Progress, Internet-Draft, draft-ietf-oauth-sd-jwt-vc-10, 7 July 2025, . + +[I-D.ietf-oauth-selective-disclosure-jwt] + +Fett, D., Yasuda, K., and B. Campbell, "Selective Disclosure for JWTs (SD-JWT)", Work in Progress, Internet-Draft, draft-ietf-oauth-selective-disclosure-jwt-22, 29 May 2025, . + +[JSON-LD] + +Kellogg, G., Champin, P., and D. Longley, "JSON-LD 1.1", 16 July 2020, . + +[OAuth.Responses] + +de Medeiros, B., Scurtescu, M., Tarjan, P., and M. Jones, "OAuth 2.0 Multiple Response Type Encoding Practices", 25 February 2014, . + +[OpenID.Core] + +Sakimura, N., Bradley, J., Jones, M.B., de Medeiros, B., and C. Mortimore, "OpenID Connect Core 1.0 incorporating errata set 2", 15 December 2023, . + +[OpenID.Federation] + +Ed., R. H., Jones, M. B., Solberg, A., Bradley, J., Marco, G. D., and V. Dzhuvinov, "OpenID Federation 1.0", 2 June 2025, . + +[OpenID4VCI] + +Lodderstedt, T., Yasuda, K., and T. Looker, "OpenID for Verifiable Credential Issuance 1.0 - draft 16", 26 June 2025, . + +[RFC2119] + +Bradner, S., "Key words for use in RFCs to Indicate Requirement Levels", BCP 14, RFC 2119, DOI 10.17487/RFC2119, March 1997, . + +[RFC3986] + +Berners-Lee, T., Fielding, R., and L. Masinter, "Uniform Resource Identifier (URI): Generic Syntax", STD 66, RFC 3986, DOI 10.17487/RFC3986, January 2005, . + +[RFC5280] + +Cooper, D., Santesson, S., Farrell, S., Boeyen, S., Housley, R., and W. Polk, "Internet X.509 Public Key Infrastructure Certificate and Certificate Revocation List (CRL) Profile", RFC 5280, DOI 10.17487/RFC5280, May 2008, . + +[RFC6125] + +Saint-Andre, P. and J. Hodges, "Representation and Verification of Domain-Based Application Service Identity within Internet Public Key Infrastructure Using X.509 (PKIX) Certificates in the Context of Transport Layer Security (TLS)", RFC 6125, DOI 10.17487/RFC6125, March 2011, . + +[RFC6749] + +Hardt, D., Ed., "The OAuth 2.0 Authorization Framework", RFC 6749, DOI 10.17487/RFC6749, October 2012, . + +[RFC7515] + +Jones, M., Bradley, J., and N. Sakimura, "JSON Web Signature (JWS)", RFC 7515, DOI 10.17487/RFC7515, May 2015, . + +[RFC7516] + +Jones, M. and J. Hildebrand, "JSON Web Encryption (JWE)", RFC 7516, DOI 10.17487/RFC7516, May 2015, . + +[RFC7517] + +Jones, M., "JSON Web Key (JWK)", RFC 7517, DOI 10.17487/RFC7517, May 2015, . + +[RFC7518] + +Jones, M., "JSON Web Algorithms (JWA)", RFC 7518, DOI 10.17487/RFC7518, May 2015, . + +[RFC7519] + +Jones, M., Bradley, J., and N. Sakimura, "JSON Web Token (JWT)", RFC 7519, DOI 10.17487/RFC7519, May 2015, . + +[RFC7591] + +Richer, J., Ed., Jones, M., Bradley, J., Machulak, M., and P. Hunt, "OAuth 2.0 Dynamic Client Registration Protocol", RFC 7591, DOI 10.17487/RFC7591, July 2015, . + +[RFC7638] + +Jones, M. and N. Sakimura, "JSON Web Key (JWK) Thumbprint", RFC 7638, DOI 10.17487/RFC7638, September 2015, . + +[RFC7800] + +Jones, M., Bradley, J., and H. Tschofenig, "Proof-of-Possession Key Semantics for JSON Web Tokens (JWTs)", RFC 7800, DOI 10.17487/RFC7800, April 2016, . + +[RFC8174] + +Leiba, B., "Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words", BCP 14, RFC 8174, DOI 10.17487/RFC8174, May 2017, . + +[RFC8414] + +Jones, M., Sakimura, N., and J. Bradley, "OAuth 2.0 Authorization Server Metadata", RFC 8414, DOI 10.17487/RFC8414, June 2018, . + +[SIOPv2] + +Yasuda, K., Jones, M. B., and T. Lodderstedt, "Self-Issued OpenID Provider V2", 28 November 2023, . + +[W3C.Digital_Credentials_API] + +Caceres, M., Cappalli, T., and M. A. Yosef, "Digital Credentials API", 1 July 2025, . + +17. Informative References + +[ETSI.TL] + +European Telecommunications Standards Institute (ETSI), "ETSI TS 119 612 V2.1.1 Electronic Signatures and Infrastructures (ESI); Trusted Lists", 2015, . + +[I-D.ietf-jose-hpke-encrypt] + +Reddy.K, T., Tschofenig, H., Banerjee, A., Steele, O., and M. B. Jones, "Use of Hybrid Public Key Encryption (HPKE) with JSON Object Signing and Encryption (JOSE)", Work in Progress, Internet-Draft, draft-ietf-jose-hpke-encrypt-11, 7 July 2025, . + +[I-D.ietf-oauth-cross-device-security] + +Kasselman, P., Fett, D., and F. Skokan, "Cross-Device Flows: Security Best Current Practice", Work in Progress, Internet-Draft, draft-ietf-oauth-cross-device-security-10, 17 June 2025, . + +[IANA.COSE] + +IANA, "CBOR Object Signing and Encryption (COSE)", . + +[IANA.Hash.Algorithms] + +IANA, "Named Information Hash Algorithm Registry", . + +[IANA.JOSE] + +IANA, "JSON Object Signing and Encryption (JOSE)", . + +[IANA.OAuth.Parameters] + +IANA, "OAuth Parameters", . + +[IANA.URI.Schemes] + +IANA, "Uniform Resource Identifier (URI) Schemes", . + +[ISO.18013-5] + +ISO/IEC JTC 1/SC 17 Cards and security devices for personal identification, "ISO/IEC 18013-5:2021 Personal identification — ISO-compliant driving license — Part 5: Mobile driving license (mDL) application", 2021, . + +[ISO.23220-2] + +ISO/IEC JTC 1/SC 17 Cards and security devices for personal identification, "ISO/IEC TS 23220-2 Personal identification — Building blocks for identity management via mobile devices, Part 2: Data objects and encoding rules for generic eID systems", 2024, . + +[ISO.23220-4] + +ISO/IEC JTC 1/SC 17 Cards and security devices for personal identification, "ISO/IEC CD TS 23220-4 Personal identification — Building blocks for identity management via mobile devices, Part 4: Protocols and services for operational phase", 2025, . + +[RFC2046] + +Freed, N. and N. Borenstein, "Multipurpose Internet Mail Extensions (MIME) Part Two: Media Types", RFC 2046, DOI 10.17487/RFC2046, November 1996, . + +[RFC6838] + +Freed, N., Klensin, J., and T. Hansen, "Media Type Specifications and Registration Procedures", BCP 13, RFC 6838, DOI 10.17487/RFC6838, January 2013, . + +[RFC8610] + +Birkholz, H., Vigano, C., and C. Bormann, "Concise Data Definition Language (CDDL): A Notational Convention to Express Concise Binary Object Representation (CBOR) and JSON Data Structures", RFC 8610, DOI 10.17487/RFC8610, June 2019, . + +[RFC8949] + +Bormann, C. and P. Hoffman, "Concise Binary Object Representation (CBOR)", STD 94, RFC 8949, DOI 10.17487/RFC8949, December 2020, . + +[RFC9101] + +Sakimura, N., Bradley, J., and M. Jones, "The OAuth 2.0 Authorization Framework: JWT-Secured Authorization Request (JAR)", RFC 9101, DOI 10.17487/RFC9101, August 2021, . + +[RFC9207] + +Meyer zu Selhausen, K. and D. Fett, "OAuth 2.0 Authorization Server Issuer Identification", RFC 9207, DOI 10.17487/RFC9207, March 2022, . + +[RFC9700] + +Lodderstedt, T., Bradley, J., Labunets, A., and D. Fett, "Best Current Practice for OAuth 2.0 Security", BCP 240, RFC 9700, DOI 10.17487/RFC9700, January 2025, . + +[VC_DATA] + +Sporny, M., Noble, G., Longley, D., Burnett, D. C., Zundel, B., and D. Chadwick, "Verifiable Credentials Data Model 1.1", 3 March 2022, . + +[VC_DATA_INTEGRITY] + +Sporny, M., Jr, T. T., Herman, I., Longley, D., and G. Bernstein, "Verifiable Credential Data Integrity 1.0", 29 March 2025, . + +Appendix A. OpenID4VP over the Digital Credentials API + +This section defines how to use OpenID4VP with the Digital Credentials API.¶ + +The name "Digital Credentials API" (DC API) encompasses the W3C Digital Credentials API [W3C.Digital_Credentials_API] +as well as its native App Platform equivalents in operating systems (such as Credential Manager on Android). +The DC API allows web sites and native apps acting as Verifiers to request the presentation of Credentials. +The API itself is agnostic to the Credential exchange protocol and can be used with different protocols. +The Web Platform, working in conjunction with other layers, such as the app platform/operating system, and based on the permission of the End-User, will send the request data along with the Origin of the Verifier to the End-User's chosen Wallet.¶ + +OpenID4VP over the DC API utilizes the mechanisms of the DC API while also allowing to leverage advanced security features of OpenID4VP, if needed. +It also defines the OpenID4VP request parameters that MAY be used with the DC API.¶ + +The DC API offers several advantages for implementers of both Verifiers and Wallets.¶ + +Firstly, the API serves as a privacy-preserving alternative to invoking Wallets via URLs, particularly custom URL schemes. The underlying app platform will only invoke a Wallet if the End-User confirms the request based on contextual information about the Credential Request and the requestor (Verifier).¶ + +Secondly, the session with the End-User will always continue in the initial context, typically a web browser tab, when the request has been fulfilled (or aborted), which results in an improved End-User experience.¶ + +Thirdly, cross-device requests benefit from the use of secure transports with proximity checks, which are handled by the OS platform, e.g., using FIDO CTAP 2.2 with hybrid transports.¶ + +And lastly, as part of the request, the Wallet is provided with information about the Verifier's Origin as authenticated by the user agent, which is important for phishing resistance.¶ + +A.1. Protocol + +To use OpenID4VP with the Digital Credentials API (DC API), the exchange protocol value has the following format: openid4vp-v-. The field is a numeric value, and explicitly specifies the type of request. This approach eliminates the need for Wallets to perform implicit parameter matching to accurately identify the version and the expected request and response parameters.¶ + +The value 1 MUST be used for the field to indicate the request and response are compatible with this version of the specification. For , unsigned requests, as defined in Appendix A.3.1, MUST use unsigned, signed requests, as defined in Appendix A.3.2.1, MUST use signed, and multi-signed requests, as defined in Appendix A.3.2.2, MUST use multisigned.¶ + +The following exchange protocol values are defined by this specification:¶ + +Unsigned requests: openid4vp-v1-unsigned¶ + + Signed requests (JWS Compact Serialization): openid4vp-v1-signed¶ + + Multi-signed requests (JWS JSON Serialization): openid4vp-v1-multisigned¶ + +A.2. Request + +The Verifier MAY send a request, as defined in Section 5, to the DC API.¶ + +The following is a non-normative example of an unsigned OpenID4VP request (when advanced security features of OpenID4VP are not used) that can be sent over the DC API:¶ + +{ + response_type: "vp_token", + response_mode: "dc_api", + nonce: "n-0S6_WzA2Mj", + client_metadata: {...}, + dcql_query: {...} +} +¶ + +Out of the Authorization Request parameters defined in [RFC6749] and Section 5, the following are supported with OpenID4VP over the W3C Digital Credentials API:¶ + + client_id¶ + + response_type¶ + + response_mode¶ + + nonce¶ + + client_metadata¶ + + request¶ + + transaction_data¶ + + dcql_query¶ + + verifier_info¶ + +The client_id parameter MUST be omitted in unsigned requests defined in Appendix A.3.1. The Wallet MUST ignore any client_id parameter that is present in an unsigned request.¶ + +Parameters defined by a specific Client Identifier Prefix (such as the trust_chain parameter for the OpenID Federation Client Identifier Prefix) are also supported over the W3C Digital Credentials API.¶ + +The client_id parameter MUST be present in signed requests defined in Appendix A.3.2, as it communicates to the Wallet which Client Identifier Prefix and Client Identifier to use when authenticating the client through verification of the request signature or retrieving client metadata. +The value of the response_mode parameter MUST be dc_api when the response is not encrypted and dc_api.jwt when the response is encrypted as defined in Section 8.3. The Response Mode dc_api causes the Wallet to send the Authorization Response via the DC API. For Response Mode dc_api.jwt, the Wallet includes the response parameter, which contains an encrypted JWT encapsulating the Authorization Response, as defined in Section 8.3.¶ + +In addition to the above-mentioned parameters, a new parameter is introduced for OpenID4VP over the W3C Digital Credentials API:¶ + + expected_origins: REQUIRED when signed requests defined in Appendix A.3.2 are used with the Digital Credentials API (DC API). A non-empty array of strings, each string representing an Origin of the Verifier that is making the request. The Wallet MUST compare values in this parameter to the Origin to detect replay of the request from a malicious Verifier. If the Origin does not match any of the entries in expected_origins, the Wallet MUST return an error. This error SHOULD be an invalid_request error. This parameter is not for use in unsigned requests and therefore a Wallet MUST ignore this parameter if it is present in an unsigned request.¶ + +The transport of the request and Origin to the Wallet is platform-specific and is out of scope of OpenID4VP over the Digital Credentials API.¶ + +Additional request parameters MAY be defined and used with OpenID4VP over the DC API.¶ + +The Wallet MUST ignore any unrecognized parameters. For example, since the state parameter is not defined for the DC API, the Verifier cannot expect it to be included in the response.¶ + +A.3. Signed and Unsigned Requests + +Any OpenID4VP request compliant to this section of this specification can be used with the Digital Credentials API (DC API). Depending on the mechanism used to identify and authenticate the Verifier, the request can be signed or unsigned. This section defines signed and unsigned OpenID4VP requests for use with the DC API.¶ + +A.3.1. Unsigned Request + +The Verifier MAY send all the OpenID4VP request parameters as members in the request member passed to the API.¶ + +A.3.2. Signed Request + +The Verifier MAY send a signed request, for example, when identification and authentication of the Verifier is required.¶ + +The signed request allows the Wallet to authenticate the Verifier using one or more trust framework(s) in addition to the Web PKI utilized by the browser. An example of such a trust framework is the Verifier (RP) management infrastructure set up in the context of the eIDAS regulation in the European Union, in which case, the Wallet can no longer rely only on the web origin of the Verifier. This web origin MAY still be used to further strengthen the security of the flow. The external trust framework could, for example, map the Client Identifier to registered web origins.¶ + +The signed Request Object MAY contain all the parameters listed in Appendix A.2, except request.¶ + +Verifiers SHOULD format signed Requests using JWS Compact Serialization but MAY use JWS JSON Serialization ([RFC7515]) to cater for the use cases described below.¶ + +A.3.2.1. JWS Compact Serialization + +When the JWS Compact Serialization is used to send the request, the Verifier can convey only one Trust Framework, i.e., the Verifier knows which trust frameworks the Wallet supports. All request parameters are encoded in a request object as defined in Section 5 and the JWS object is used as the value of the request claim in the data element of the API call.¶ + +This is illustrated in the following non-normative example.¶ + +{ request: "eyJhbGciOiJF..." } +¶ + +This is an example of the payload of a signed OpenID4VP request used with the W3C Digital Credentials API in conjunction with JWS Compact Serialization:¶ + +{ + "expected_origins": [ + "https://origin1.example.com", + "https://origin2.example.com" + ], + "client_id": "x509_san_dns:rp.example.com", + "client_metadata": { + "jwks": { + "keys": [ + { + "kty": "EC", + "crv": "P-256", + "x": "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", + "y": "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM", + "use": "enc", + "kid": "1" + } + ] + } + }, + "response_type": "vp_token", + "response_mode": "dc_api", + "nonce": "n-0S6_WzA2Mj", + "dcql_query": {...} +} +¶ + +A.3.2.2. JWS JSON Serialization + +The JWS JSON Serialization ([RFC7515]) allows the Verifier to use multiple Client Identifiers and corresponding key material to protect the same request. This serves use cases where the Verifier requests Credentials belonging to different trust frameworks and, therefore, needs to authenticate in the context of those trust frameworks. It also allows the Verifier to add different attestations for each Client Identifier.¶ + +In this case, the following request parameters, if used, MUST be present only in the protected header of the respective signature object in the signatures array defined in Section 7.2.1 of [RFC7515]:¶ + + client_id¶ + + verifier_info¶ + + parameters that are specific to a Client Identifier Prefix, e.g., the trust_chain JWS header parameter for the openid_federation Client Identifier Prefix¶ + +All other request parameters MUST be present in the payload element of the JWS object.¶ + +Below is a non-normative example of such a request:¶ + +{ + "payload": "eyAiaXNzIjogImh0dHBzOi8...NzY4Mzc4MzYiIF0gfQ", + "signatures": [ + { + "protected": "eyJhbGciOiAiRVMyNT..MiLCJraWQiOiAiMSJ9XX19fQ", + "signature": "PFwem0Ajp2Sag...T2z784h8TQqgTR9tXcif0jw" + }, + { + "protected": "eyJhbGciOiAiRVMyNTY...tpZCI6ICIxIn1dfX19", + "signature": "irgtXbJGwE2wN4Lc...2TvUodsE0vaC-NXpB9G39cMXZ9A" + } + ] +} +¶ + +Every object in the signatures structure contains the parameters and the signature specific to a particular Client Identifier. The signature is calculated as specified in section 5.1 of [RFC7515].¶ + +The following is a non-normative example of the content of a decoded protected header:¶ + +{ + "alg": "ES256", + "x5c": [ + "MIICOjCCAeG...djzH7lA==", + "MIICLTCCAdS...koAmhWVKe" + ], + "client_id": "x509_san_dns:rp.example.com" +} +¶ + +The following is a non-normative example of the payload of a signed OpenID4VP request used with the W3C Digital Credentials API in conjunction with JWS JSON Serialization:¶ + +{ + "expected_origins": [ + "https://origin1.example.com", + "https://origin2.example.com" + ], + "response_type": "vp_token", + "response_mode": "dc_api", + "nonce": "n-0S6_WzA2Mj", + "dcql_query": {...}, + "client_metadata": { + "jwks": { + "keys": [ + { + "kty": "EC", + "crv": "P-256", + "x": "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", + "y": "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM", + "use": "enc", + "kid": "1" + } + ] + } + } +} +¶ + +A.4. Response + +Every OpenID4VP Request results in a response being provided through the Digital Credentials API (DC API), or in a canceled flow. If a response is provided, the response is an instance of the DigitalCredential interface, as defined in [W3C.Digital_Credentials_API], and the OpenID4VP Response parameters as defined for the Response Type are represented as an object within the data property.¶ + +Protocol error responses are returned as an object within the data property. This object has a single property with the name error and a value containing the error response code as defined in Section 8.5. Note that a protocol error generated by the Wallet will still result in a fulfilled promise for the Digital Credentials API request. Privacy considerations specific to returning error responses over the Digital Credentials API can be found in Section 15.9.2.¶ + +The following is a non-normative example of a data object containing an error:¶ + +{ + "error": "invalid_request" +} +¶ + +The security properties that are normally provided by the Client Identifier are achieved by binding the response to the Origin it was received from.¶ + +The audience for the response (for example, the aud value in a Key Binding JWT) MUST be the Origin, prefixed with origin:, for example origin:https://verifier.example.com/. This is the case even for signed requests. Therefore, when using OpenID4VP over the DC API, the Client Identifier is not used as the audience for the response.¶ + +A.5. Security Considerations + +The following security considerations from OpenID4VP apply:¶ + +Preventing Replay of Verifiable Presentations as described in Section 14.1, with the difference that the origin is used instead of the Client Identifier to bind the response to the Client.¶ + + End-User Authentication using Credentials as described in Section 14.4.¶ + + Encrypting an Unsigned Response as described in Section 14.5.¶ + + TLS Requirements as described in Section 14.6.¶ + + Always Use the Full Client Identifier as described in Section 14.8 for signed requests.¶ + + Security Checks on the Returned Credentials and Presentations as described in Section 14.9.¶ + + DCQL Value Matching as described in Section 15.4.1.¶ + +A.6. Privacy Considerations + +The following privacy considerations from OpenID4VP apply:¶ + +Selective Disclosure as described in Section 15.4.¶ + + Privacy implications of mechanisms to establish trust in Issuers as described in Section 15.10.¶ + +Appendix B. Credential Format Specific Parameters and Rules + +OpenID for Verifiable Presentations is Credential Format agnostic, i.e., it is designed to allow applications to request and receive Presentations in any Credential Format. This section defines a set of Credential Format specific parameters and rules for some of the known Credential Formats. For the Credential Formats that are not mentioned in this specification, other specifications or deployments can define their own set of Credential Format specific parameters.¶ + +B.1. W3C Verifiable Credentials + +The following sections define the Credential Format specific parameters and rules for W3C Verifiable Credentials compliant to the [VC_DATA] specification and for W3C Verifiable Presentations of such Credentials.¶ + +If require_cryptographic_holder_binding is set to true in the Credential Query, the Wallet MUST return a Verifiable Presentation of a Verifiable Credential. Otherwise, a Verifiable Credential without Holder Binding MUST be returned.¶ + +B.1.1. Parameters in the meta parameter in Credential Query + +The following is a W3C Verifiable Credentials specific parameter in the meta parameter in a Credential Query as defined in Section 6.1:¶ + +type_values: + REQUIRED. A non-empty array of string arrays. The value of each element in the type_values array is a non-empty array specifying the fully expanded types (IRIs) that the Verifier accepts in a Presentation, after applying the @context to the Verifiable Credential. If a type value in a Verifiable Credential is not defined in any @context, it remains unchanged, i.e., remains a relative IRI after JSON-LD processing. For this reason, JSON-LD processing MAY be skipped in such cases and the relative IRI is considered to be the fully expanded type, as applying the @context would not alter the value. Implementations MAY use alternative mechanisms to obtain the fully expanded types, as long as the results are equivalent to those produced by JSON-LD processing. Each of the top-level arrays specifies one alternative to match the fully expanded type values of the Verifiable Credential against. Each inner array specifies a set of fully expanded types that MUST be present in the fully expanded types in the type property of the Verifiable Credential, regardless of order or the presence of additional types.¶ + +The following is a non-normative example of type_values within a DCQL query:¶ + +"type_values":[ + [ + "https://www.w3.org/2018/credentials#VerifiableCredential", + "https://example.org/examples#AlumniCredential", + "https://example.org/examples#BachelorDegree" + ], + [ + "https://www.w3.org/2018/credentials#VerifiableCredential", + "https://example.org/examples#UniversityDegreeCredential" + ], + [ + "IdentityCredential" + ] +] +¶ + +The following is a non-normative example of a W3C Verifiable Credential that would match the type_values DCQL query above (other claims omitted for readability):¶ + +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "type": ["VerifiableCredential", "UniversityDegreeCredential"] +} +¶ + +The following is another non-normative example of a W3C Verifiable Credential that would match the type_values DCQL query above (other claims omitted for readability):¶ + +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "type": ["VerifiableCredential", "BachelorDegree", "AlumniCredential"] +} +¶ + +The following is another non-normative example of a W3C Verifiable Credential that would match the type_values DCQL query above (other claims omitted for readability):¶ + +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1" + ], + "type": ["VerifiableCredential", "IdentityCredential"] +} +¶ + +B.1.2. Claims Matching + +The claims_path parameter in the Credential Query as defined in Section 6.1 is used to specify the claims that the Verifier wants to receive in the Presentation. When used in the context of W3C Verifiable Credentials, the claims_path parameter always matches on the root of Verifiable Credential (not the Verifiable Presentation). Examples are shown in the following subsections.¶ + +B.1.3. Formats and Examples + +B.1.3.1. VC signed as a JWT, not using JSON-LD + +This section illustrates the presentation of a Credential conformant to [VC_DATA] that is signed using JWS, and does not use JSON-LD.¶ + +B.1.3.1.1. Format Identifier and Cipher Suites + +The Credential Format Identifier is jwt_vc_json to request a W3C Verifiable Credential compliant to the [VC_DATA] specification or a Verifiable Presentation of such a Credential.¶ + +Cipher suites should use algorithm names defined in IANA JOSE Algorithms Registry.¶ + +B.1.3.1.2. Example Credential + +The following is a non-normative example of the payload of a JWT-based W3C Verifiable Credential that will be used throughout this section:¶ + +{ + "iss": "https://example.gov/issuers/565049", + "nbf": 1262304000, + "jti": "http://example.gov/credentials/3732", + "sub": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "vc": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "type": [ + "VerifiableCredential", + "IDCredential" + ], + "credentialSubject": { + "given_name": "Max", + "family_name": "Mustermann", + "birthdate": "1998-01-11", + "address": { + "street_address": "Sandanger 25", + "locality": "Musterstadt", + "postal_code": "123456", + "country": "DE" + } + } + } +} +¶ + +B.1.3.1.3. Metadata + +The vp_formats_supported parameter of the Verifier metadata or Wallet metadata MUST have the Credential Format Identifier as a key, and the value MUST be an object consisting of the following name/value pair:¶ + + alg_values: OPTIONAL. A non-empty array containing identifiers of cryptographic algorithms supported for a JWT-secured W3C Verifiable Credential or W3C Verifiable Presentation. If present, the alg JOSE header (as defined in [RFC7515]) of the presented Verifiable Credential or Verifiable Presentation MUST match one of the array values.¶ + +The following is a non-normative example of client_metadata request parameter value in a request to present an W3C Verifiable Presentation.¶ + +{ + "vp_formats_supported": { + "jwt_vc_json": { + "alg_values": ["ES256", "ES384"] + } + } +} +¶ + +B.1.3.1.4. Presentation Request + +The requirements regarding the Credential to be presented are conveyed in the dcql_query parameter.¶ + +The following is a non-normative example of the contents of this parameter:¶ + +{ + "credentials": [ + { + "id": "example_jwt_vc", + "format": "jwt_vc_json", + "meta": { + "type_values": [["IDCredential"]] + }, + "claims": [ + {"path": ["credentialSubject", "family_name"]}, + {"path": ["credentialSubject", "given_name"]} + ] + } + ] +} +¶ + +B.1.3.1.5. Presentation Response + +The following requirements apply to the nonce and aud claims of the Verifiable Presentation:¶ + +the nonce claim MUST be the value of nonce from the Authorization Request;¶ + + the aud claim MUST be the value of the Client Identifier, except for requests over the DC API where it MUST be the Origin prefixed with origin:, as described in Appendix A.4.¶ + +The following is a non-normative example of the VP Token provided in the response (shortened for presentation):¶ + +{ + "example_jwt_vc": ["eY...QMA"] +} +¶ + +The following is a non-normative example of the payload of the Verifiable Presentation in the VP Token in the last example:¶ + +{ + "iss": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "jti": "urn:uuid:3978344f-8596-4c3a-a978-8fcaba3903c5", + "aud": "x509_san_dns:client.example.org", + "nbf": 1541493724, + "iat": 1541493724, + "exp": 1573029723, + "nonce": "n-0S6_WzA2Mj", + "vp": { + "@context": [ + "https://www.w3.org/2018/credentials/v1" + ], + "type": [ + "VerifiablePresentation" + ], + "verifiableCredential": [ + "eyJhb...ssw5c" + ] + } +} +¶ + +B.1.3.2. LDP VCs + +This section illustrates presentation of a Credential conformant to [VC_DATA] that is secured using Data Integrity, using JSON-LD.¶ + +B.1.3.2.1. Format Identifier and Cipher Suites + +The Credential Format Identifier is ldp_vc to request a W3C Verifiable Credential compliant to the [VC_DATA] specification or a Verifiable Presentation of such a Credential.¶ + +Cipher suites should use Data Integrity compatible securing mechanisms defined in Verifiable Credential Extensions.¶ + +B.1.3.2.2. Example Credential + +The following is a non-normative example of the payload of a Verifiable Credential that will be used throughout this section:¶ + +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1", + "https://w3id.org/security/data-integrity/v2" + ], + "id": "https://example.com/credentials/1872", + "type": [ + "VerifiableCredential", + "IDCredential" + ], + "issuer": { + "id": "did:example:issuer" + }, + "issuanceDate": "2025-03-19T00:00:00Z", + "credentialSubject": { + "given_name": "Max", + "family_name": "Mustermann", + "birthdate": "1998-01-11", + "address": { + "street_address": "Sandanger 25", + "locality": "Musterstadt", + "postal_code": "123456", + "country": "DE" + } + }, + "proof": { + "type": "DataIntegrityProof", + "cryptosuite": "eddsa-rdfc-2022", + "created": "2025-03-19T15:30:15Z", + "proofValue": "z5C5b...EtszK", + "proofPurpose": "assertionMethod", + "verificationMethod": "did:example:issuer#keys-1" + } +} +¶ + +B.1.3.2.3. Metadata + +The vp_formats_supported parameter of the Verifier metadata or Wallet metadata MUST have the Credential Format Identifier as a key, and the value MUST be an object consisting of the following name/value pairs:¶ + + proof_type_values: OPTIONAL. A non-empty array containing identifiers of proof types supported for a Data Integrity secured W3C Verifiable Presentation or W3C Verifiable Credential. If present, the proof type parameter (as defined in [VC_DATA]) of the presented Verifiable Credential or Verifiable Presentation MUST match one of the array values.¶ + + cryptosuite_values: OPTIONAL. A non-empty array containing identifiers of crypto suites supported with one of the algorithms listed in proof_type_values for a Data Integrity secured W3C Verifiable Presentation or W3C Verifiable Credential. Note that cryptosuite_values MAY be used if one of the algorithms in proof_type_values supports multiple crypto suites. If present, the proof cryptosuite parameter (as defined in [VC_DATA_INTEGRITY]) of the presented Verifiable Credential or Verifiable Presentation MUST match one of the array values.¶ + +The following is a non-normative example of client_metadata request parameter value in a request to present an W3C Verifiable Presentation.¶ + +{ + "vp_formats_supported": { + "ldp_vc": { + "proof_type_values": [ + "DataIntegrityProof", + "Ed25519Signature2020" + ], + "cryptosuite_values": [ + "ecdsa-rdfc-2019", + "ecdsa-sd-2023", + "ecdsa-jcs-2019", + "bbs-2023" + ] + } + } +} +¶ + +B.1.3.2.4. Presentation Request + +The requirements regarding the Credential to be presented are conveyed in the dcql_query parameter.¶ + +The following is a non-normative example of the contents of this parameter:¶ + +{ + "credentials": [ + { + "id": "example_ldp_vc", + "format": "ldp_vc", + "meta": { + "type_values": [["IDCredential"]] + }, + "claims": [ + {"path": ["credentialSubject", "family_name"]}, + {"path": ["credentialSubject", "given_name"]}, + {"path": ["credentialSubject", "birthdate"]}, + {"path": ["credentialSubject", "address", "street_address"]}, + {"path": ["credentialSubject", "address", "locality"]}, + {"path": ["credentialSubject", "address", "postal_code"]}, + {"path": ["credentialSubject", "address", "country"]} + ] + } + ] +} +¶ + +B.1.3.2.5. Presentation Response + +The following requirements apply to the challenge and domain claims within the proof object in the Verifiable Presentation:¶ + +the challenge claim MUST be the value of nonce from the Authorization Request;¶ + + the domain claim MUST be the value of the Client Identifier, except for requests over the DC API where it MUST be the Origin prefixed with origin:, as described in Appendix A.4.¶ + +The following is a non-normative example of the Verifiable Presentation in the vp_token parameter:¶ + +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://w3id.org/security/data-integrity/v2" + ], + "type": [ + "VerifiablePresentation" + ], + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1", + "https://w3id.org/security/data-integrity/v2" + ], + "id": "https://example.com/credentials/1872", + "type": [ + "VerifiableCredential", + "IDCredential" + ], + "issuer": { + "id": "did:example:issuer" + }, + "issuanceDate": "2025-03-19T00:00:00Z", + "credentialSubject": { + "given_name": "Max", + "family_name": "Mustermann", + "birthdate": "1998-01-11", + "address": { + "street_address": "Sandanger 25", + "locality": "Musterstadt", + "postal_code": "123456", + "country": "DE" + } + }, + "proof": { + "type": "DataIntegrityProof", + "cryptosuite": "eddsa-rdfc-2022", + "created": "2025-03-19T15:30:15Z", + "proofValue": "z5C5b...EtszK", + "proofPurpose": "assertionMethod", + "verificationMethod": "did:example:issuer#keys-1" + } + } + ], + "id": "ebc6f1c2", + "holder": "did:example:holder", + "proof": { + "type": "DataIntegrityProof", + "cryptosuite": "eddsa-rdfc-2022", + "created": "2025-04-04T10:12:15Z", + "challenge": "n-0S6_WzA2Mj", + "domain": "x509_san_dns:client.example.org", + "proofValue": "z5s8c...AD3a9d", + "proofPurpose": "authentication", + "verificationMethod": "did:example:holder#key-1" + } +} +¶ + +B.2. Mobile Documents or mdocs (ISO/IEC 18013 and ISO/IEC 23220 series) + +ISO/IEC 18013-5:2021 [ISO.18013-5] defines a mobile driving license (mDL) Credential in the mobile document (mdoc) format. Although ISO/IEC 18013-5:2021 [ISO.18013-5] is specific to mobile driving licenses (mDLs), the Credential format can be utilized with any type of Credential (or mdoc document types). The ISO/IEC 23220 series has extracted components from ISO/IEC 18013-5:2021 [ISO.18013-5] that are common across document types to facilitate the profiling of the specification for other document types. The core data structures are shared between ISO/IEC 18013-5:2021 [ISO.18013-5], ISO/IEC 23220-2 [ISO.23220-2], ISO/IEC 23220-4 [ISO.23220-4] which are encoded in CBOR and secured using COSE_Sign1.¶ + +The Credential Format Identifier for Credentials in the mdoc format is mso_mdoc.¶ + +B.2.1. Transaction Data + +It is RECOMMENDED that each transaction data type defines a data element (NameSpace, DataElementIdentifier, DataElementValue) to be used to return the processed transaction data. Additionally, it is RECOMMENDED that it specifies the processing rules, potentially including any hash function to be applied, and the expected resulting structure.¶ + +Some document types support some transaction data (Section 8.4) to be protected using mdoc authentication, as part of the DeviceSigned data structure [ISO.18013-5]. In those cases, the specifications of these document types include which transaction data types are supported, and the issuer includes the relevant data elements in the KeyAuthorizations. If a Wallet receives a request with a transaction_data type whose data element is unauthorized, the Wallet MUST reject the request due to an unsupported transaction data type.¶ + +B.2.2. Metadata + +The vp_formats_supported parameter of the Verifier metadata or Wallet metadata MUST have the Credential Format Identifier as a key, and the value MUST be an object consisting of the following name/value pairs:¶ + + issuerauth_alg_values: OPTIONAL. A non-empty array containing cryptographic algorithm identifiers. The Credential MUST be considered to fulfill the requirement(s) expressed in this parameter if one of the following is true: 1) The value in the array matches the 'alg' value in the IssuerAuth COSE header. 2) The value in the array is a fully specified algorithm according to [I-D.ietf-jose-fully-specified-algorithms] and the combination of the alg value in the IssuerAuth COSE header and the curve used by the signing key of the COSE structure matches the combination of the algorithm and curve identified by the fully specified algorithm. As an example, if the IssuerAuth structure contains an alg header with value -7 (which stands for ECDSA with SHA-256 in [IANA.COSE]) and is signed by a P-256 key, then it matches an issuerauth_alg_values element of -7 and -9 (which stands for ECDSA using P-256 curve and SHA-256 in [I-D.ietf-jose-fully-specified-algorithms]).¶ + + deviceauth_alg_values: OPTIONAL. A non-empty array containing cryptographic algorithm identifiers. The Credential MUST be considered to fulfill the requirement(s) expressed in this parameter if one of the following is true: 1) The value in the array matches the 'alg' value in the DeviceSignature or DeviceMac COSE header. 2) The value in the array is a fully-specified algorithm according to [I-D.ietf-jose-fully-specified-algorithms] and the combination of the alg value in the DeviceSignature COSE header and the curve used by the signing key of the COSE structure matches the combination of the algorithm and curve identified by the fully-specified algorithm. 3) The alg of the DeviceMac COSE header is HMAC 256/256 (as described in Section 9.1.3.5 of [ISO.18013-5]) and the curve of the device key (from Table 22 of [ISO.18013-5]) matches a value in the array using the identifiers defined in the following table:¶ + +Table 2: +Mapping of curves to alg identifiers used for the HMAC 256/256 case + + Algorithm Name + Algorithm Value + + HMAC 256/256 using ECDH with Curve P-256 + -65537 + + HMAC 256/256 using ECDH with Curve P-384 + -65538 + + HMAC 256/256 using ECDH with Curve P-521 + -65539 + + HMAC 256/256 using ECDH with X25519 + -65540 + + HMAC 256/256 using ECDH with X448 + -65541 + + HMAC 256/256 using ECDH with brainpoolP256r1 + -65542 + + HMAC 256/256 using ECDH with brainpoolP320r1 + -65543 + + HMAC 256/256 using ECDH with brainpoolP384r1 + -65544 + + HMAC 256/256 using ECDH with brainpoolP512r1 + -65545 + +Note: These are specified in OpenID4VP only for private use in this parameter in this specification, and might be superseded by a future registration in IANA.¶ + +For clarity, the following is a couple of non-normative examples of the deviceauth_alg_values parameter¶ + +The example below indicates the verifier supports DeviceMac with HMAC 256/256, where the MAC key is established via ECDH using keys on the P-256 curve as per Section 9.1.3.5 of [ISO.18013-5].¶ + +{ + "deviceauth_alg_values": [ -65537 ] +} +¶ + +The example below indicates the verifier supports DeviceMac with HMAC 256/256, where the MAC key is established via ECDH using keys on the P-256 curve as per Section 9.1.3.5 of [ISO.18013-5], and DeviceSignature using ECDSA with the P-256 curve.¶ + +{ + "deviceauth_alg_values": [ -65537, -9 ] +} +¶ + +The following is a non-normative example of client_metadata request parameter value in a request to present an ISO/IEC 18013-5 mDOC.¶ + +{ + "vp_formats_supported": { + "mso_mdoc": { + "issuerauth_alg_values": [-9, -50], + + "deviceauth_alg_values": [-9, -50] + } + } +} +¶ + +B.2.3. Parameter in the meta parameter in Credential Query + +The following is an ISO mdoc specific parameter in the meta parameter in a Credential Query as defined in Section 6.1.¶ + +doctype_value: + REQUIRED. String that specifies an allowed value for the +doctype of the requested Verifiable Credential. It MUST +be a valid doctype identifier as defined in [ISO.18013-5].¶ + +B.2.4. Parameter in the Claims Query + +The following are ISO mdoc specific parameters to be used in a Claims Query as defined in Section 6.3.¶ + + intent_to_retain + OPTIONAL. A boolean that is equivalent to IntentToRetain variable defined in Section 8.3.2.1.2.1 of [ISO.18013-5].¶ + +B.2.5. Presentation Response + +An example DCQL query using the mdoc format is shown in Appendix D. The following is a non-normative example for a VP Token in the response:¶ + +{ + "my_credential": [""] +} +¶ + +The VP Token contains the base64url-encoded DeviceResponse CBOR structure as defined in ISO/IEC 18013-5 [ISO.18013-5] or ISO/IEC 23220-4 [ISO.23220-4]. Essentially, the DeviceResponse CBOR structure contains a signature or MAC over the SessionTranscript CBOR structure including the OpenID4VP-specific Handover CBOR structure.¶ + +B.2.6. Handover and SessionTranscript Definitions + +B.2.6.1. Invocation via Redirects + +If the presentation request is invoked using redirects, the SessionTranscript CBOR structure as defined in Section 9.1.5.1 in [ISO.18013-5] MUST be used with the following changes:¶ + + DeviceEngagementBytes MUST be null.¶ + + EReaderKeyBytes MUST be null.¶ + + Handover MUST be the OpenID4VPHandover CBOR structure as defined below.¶ + +OpenID4VPHandover = [ + "OpenID4VPHandover", ; A fixed identifier for this handover type + OpenID4VPHandoverInfoHash ; A cryptographic hash of OpenID4VPHandoverInfo +] + +; Contains the sha-256 hash of OpenID4VPHandoverInfoBytes +OpenID4VPHandoverInfoHash = bstr + +; Contains the bytes of OpenID4VPHandoverInfo encoded as CBOR +OpenID4VPHandoverInfoBytes = bstr .cbor OpenID4VPHandoverInfo + +OpenID4VPHandoverInfo = [ + clientId, + nonce, + jwkThumbprint, + responseUri +] ; Array containing handover parameters + +clientId = tstr + +nonce = tstr + +jwkThumbprint = bstr + +responseUri = tstr +¶ + +The OpenID4VPHandover structure has the following elements:¶ + +The first element MUST be the string OpenID4VPHandover. This serves as a unique identifier for the handover structure to prevent misinterpretation or confusion.¶ + + The second element MUST be a Byte String which contains the sha-256 hash of the bytes of OpenID4VPHandoverInfo when encoded as CBOR.¶ + + The OpenID4VPHandoverInfo has the following elements:¶ + +The first element MUST be the client_id request parameter. If applicable, this includes the Client Identifier Prefix.¶ + + The second element MUST be the value of the nonce request parameter.¶ + + If the response is encrypted, e.g., using direct_post.jwt, the third element MUST be the JWK SHA-256 Thumbprint as defined in [RFC7638], encoded as a Byte String, of the Verifier's public key used to encrypt the response. Otherwise, the third element MUST be null. See Appendix B.2.6.2 for an explanation of why this is important.¶ + + The fourth element MUST be either the redirect_uri or response_uri request parameter, depending on which is present, as determined by the Response Mode.¶ + +Unless otherwise stated, the values of client_id, nonce, redirect_uri, and response_uri request parameters referenced above MUST be obtained from the Authorization Request query parameters if the request is unsigned, or from the signed Request Object if the request is signed.¶ + +The following is a non-normative example of the input JWK for calculating the JWK Thumbprint in the context of OpenID4VPHandoverInfo:¶ + +{ + "kty": "EC", + "crv": "P-256", + "x": "DxiH5Q4Yx3UrukE2lWCErq8N8bqC9CHLLrAwLz5BmE0", + "y": "XtLM4-3h5o3HUH0MHVJV0kyq0iBlrBwlh8qEDMZ4-Pc", + "use": "enc", + "alg": "ECDH-ES", + "kid": "1" +} +¶ + +The following is a non-normative example of the OpenID4VPHandoverInfo structure:¶ + +Hex: + +847818783530395f73616e5f646e733a6578616d706c652e636f6d782b6578633767 +426b786a7831726463397564527276654b7653734a4971383061766c58654c486847 +7771744158204283ec927ae0f208daaa2d026a814f2b22dca52cf85ffa8f3f8626c6 +bd669047781c68747470733a2f2f6578616d706c652e636f6d2f726573706f6e7365 + +CBOR diagnostic: + +84 # array(4) + 78 18 # string(24) + 783530395f73616e5f646e733a6578 # "x509_san_dns:ex" + 616d706c652e636f6d # "ample.com" + 78 2b # string(43) + 6578633767426b786a783172646339 # "exc7gBkxjx1rdc9" + 7564527276654b7653734a49713830 # "udRrveKvSsJIq80" + 61766c58654c48684777717441 # "avlXeLHhGwqtA" + 58 20 # bytes(32) + 4283ec927ae0f208daaa2d026a814f # "B\x83ì\x92zàò\x08Úª-\x02j\x81O" + 2b22dca52cf85ffa8f3f8626c6bd66 # "+"Ü¥,ø_ú\x8f?\x86&ƽf" + 9047 # "\x90G" + 78 1c # string(28) + 68747470733a2f2f6578616d706c65 # "https://example" + 2e636f6d2f726573706f6e7365 # ".com/response" +¶ + +The following is a non-normative example of the OpenID4VPHandover structure:¶ + +Hex: + +82714f70656e494434565048616e646f7665725820048bc053c00442af9b8eed494c +efdd9d95240d254b046b11b68013722aad38ac + +CBOR diagnostic: + +82 # array(2) + 71 # string(17) + 4f70656e494434565048616e646f76 # "OpenID4VPHandov" + 6572 # "er" + 58 20 # bytes(32) + 048bc053c00442af9b8eed494cefdd # "\x04\x8bÀSÀ\x04B¯\x9b\x8eíILïÝ" + 9d95240d254b046b11b68013722aad # "\x9d\x95$\x0d%K\x04k\x11¶\x80\x13r*­" + 38ac # "8¬" +¶ + +The following is a non-normative example of the SessionTranscript structure:¶ + +Hex: + +83f6f682714f70656e494434565048616e646f7665725820048bc053c00442af9b8e +ed494cefdd9d95240d254b046b11b68013722aad38ac + +CBOR diagnostic: + +83 # array(3) + f6 # null + f6 # null + 82 # array(2) + 71 # string(17) + 4f70656e494434565048616e646f # "OpenID4VPHando" + 766572 # "ver" + 58 20 # bytes(32) + 048bc053c00442af9b8eed494cef # "\x04\x8bÀSÀ\x04B¯\x9b\x8eíILï" + dd9d95240d254b046b11b6801372 # "Ý\x9d\x95$\x0d%K\x04k\x11¶\x80\x13r" + 2aad38ac # "*­8¬" +¶ + +B.2.6.2. Invocation via the Digital Credentials API + +If the presentation request is invoked using the Digital Credentials API, the SessionTranscript CBOR structure as defined in Section 9.1.5.1 in [ISO.18013-5] MUST be used with the following changes:¶ + + DeviceEngagementBytes MUST be null.¶ + + EReaderKeyBytes MUST be null.¶ + + Handover MUST be the OpenID4VPDCAPIHandover CBOR structure as defined below.¶ + +Note: The following section contains a definition in Concise Data Definition Language (CDDL), a language used to define data structures - see [RFC8610] for more details. bstr refers to Byte String, defined as major type 2 in CBOR and tstr refers to Text String, defined as major type 3 in CBOR (encoded in utf-8) as defined in section 3.1 of [RFC8949].¶ + +OpenID4VPDCAPIHandover = [ + "OpenID4VPDCAPIHandover", ; A fixed identifier for this handover type + OpenID4VPDCAPIHandoverInfoHash ; A cryptographic hash of OpenID4VPDCAPIHandoverInfo +] + +; Contains the sha-256 hash of OpenID4VPDCAPIHandoverInfoBytes +OpenID4VPDCAPIHandoverInfoHash = bstr + +; Contains the bytes of OpenID4VPDCAPIHandoverInfo encoded as CBOR +OpenID4VPDCAPIHandoverInfoBytes = bstr .cbor OpenID4VPDCAPIHandoverInfo + +OpenID4VPDCAPIHandoverInfo = [ + origin, + nonce, + jwkThumbprint +] ; Array containing handover parameters + +origin = tstr + +nonce = tstr + +jwkThumbprint = bstr +¶ + +The OpenID4VPDCAPIHandover structure has the following elements:¶ + +The first element MUST be the string OpenID4VPDCAPIHandover. This serves as a unique identifier for the handover structure to prevent misinterpretation or confusion.¶ + + The second element MUST be a Byte String which contains the sha-256 hash of the bytes of OpenID4VPDCAPIHandoverInfo when encoded as CBOR.¶ + + The OpenID4VPDCAPIHandoverInfo has the following elements:¶ + +The first element MUST be the string representing the Origin of the request as described in Appendix A.2. It MUST NOT be prefixed with origin:.¶ + + The second element MUST be the value of the nonce request parameter.¶ + + For the Response Mode dc_api.jwt, the third element MUST be the JWK SHA-256 Thumbprint as defined in [RFC7638], encoded as a Byte String, of the Verifier's public key used to encrypt the response. If the Response Mode is dc_api, the third element MUST be null. For unsigned requests, including the JWK Thumbprint in the SessionTranscript allows the Verifier to detect whether the response was re-encrypted by a third party, potentially leading to the leakage of sensitive information. While this does not prevent such an attack, it makes it detectable and helps preserve the confidentiality of the response.¶ + +The following is a non-normative example of the input JWK for calculating the JWK Thumbprint in the context of OpenID4VPDCAPIHandoverInfo:¶ + +{ + "kty": "EC", + "crv": "P-256", + "x": "DxiH5Q4Yx3UrukE2lWCErq8N8bqC9CHLLrAwLz5BmE0", + "y": "XtLM4-3h5o3HUH0MHVJV0kyq0iBlrBwlh8qEDMZ4-Pc", + "use": "enc", + "alg": "ECDH-ES", + "kid": "1" +} +¶ + +The following is a non-normative example of the OpenID4VPDCAPIHandoverInfo structure:¶ + +Hex: + +837368747470733a2f2f6578616d706c652e636f6d782b6578633767426b786a7831 +726463397564527276654b7653734a4971383061766c58654c486847777174415820 +4283ec927ae0f208daaa2d026a814f2b22dca52cf85ffa8f3f8626c6bd669047 + +CBOR diagnostic: + +83 # array(3) + 73 # string(19) + 68747470733a2f2f6578616d706c65 # "https://example" + 2e636f6d # ".com" + 78 2b # string(43) + 6578633767426b786a783172646339 # "exc7gBkxjx1rdc9" + 7564527276654b7653734a49713830 # "udRrveKvSsJIq80" + 61766c58654c48684777717441 # "avlXeLHhGwqtA" + 58 20 # bytes(32) + 4283ec927ae0f208daaa2d026a814f # "B\x83ì\x92zàò\x08Úª-\x02j\x81O" + 2b22dca52cf85ffa8f3f8626c6bd66 # "+"Ü¥,ø_ú\x8f?\x86&ƽf" + 9047 # "\x90G" +¶ + +The following is a non-normative example of the OpenID4VPDCAPIHandover structure:¶ + +Hex: + +82764f70656e4944345650444341504948616e646f7665725820fbece366f4212f97 +62c74cfdbf83b8c69e371d5d68cea09cb4c48ca6daab761a + +CBOR diagnostic: + +82 # array(2) + 76 # string(22) + 4f70656e4944345650444341504948 # "OpenID4VPDCAPIH" + 616e646f766572 # "andover" + 58 20 # bytes(32) + fbece366f4212f9762c74cfdbf83b8 # "ûìãfô!/\x97bÇLý¿\x83¸" + c69e371d5d68cea09cb4c48ca6daab # "Æ\x9e7\x1d]hÎ\xa0\x9c´Ä\x8c¦Ú«" + 761a # "v\x1a" +¶ + +The following is a non-normative example of the SessionTranscript structure:¶ + +Hex: + +83f6f682764f70656e4944345650444341504948616e646f7665725820fbece366f4 +212f9762c74cfdbf83b8c69e371d5d68cea09cb4c48ca6daab761a + +CBOR diagnostic: + +83 # array(3) + f6 # null + f6 # null + 82 # array(2) + 76 # string(22) + 4f70656e49443456504443415049 # "OpenID4VPDCAPI" + 48616e646f766572 # "Handover" + 58 20 # bytes(32) + fbece366f4212f9762c74cfdbf83 # "ûìãfô!/\x97bÇLý¿\x83" + b8c69e371d5d68cea09cb4c48ca6 # "¸Æ\x9e7\x1d]hÎ\xa0\x9c´Ä\x8c¦" + daab761a # "Ú«v\x1a" +¶ + +B.3. IETF SD-JWT VC + +This section defines how Credentials complying with [I-D.ietf-oauth-sd-jwt-vc] can be presented to the Verifier using this specification.¶ + +If require_cryptographic_holder_binding is set to true in the Credential Query, the Wallet MUST return an SD-JWT [I-D.ietf-oauth-selective-disclosure-jwt] with a Key Binding JWT (SD-JWT+KB) as the Verifiable Presentation. SD-JWTs that do not support Holder Binding (i.e., do not have a cnf Claim) cannot be returned in this case. +If require_cryptographic_holder_binding is set to false, an SD-JWT without the Key Binding JWT MAY be returned.¶ + +B.3.1. Format Identifier + +The Credential Format Identifier is dc+sd-jwt.¶ + +B.3.2. Example Credential + +The following is a non-normative example of the unsecured payload of an IETF SD-JWT VC that will be used throughout this section:¶ + +{ + "vct": "https://credentials.example.com/identity_credential", + "given_name": "John", + "family_name": "Doe", + "birthdate": "1940-01-01" +} +¶ + +The following is a non-normative example of an IETF SD-JWT VC using the unsecured payload above, containing claims that are selectively disclosable.¶ + +{ + "_sd": [ + "3oUCnaKt7wqDKuyh-LgQozzfhgb8gO5Ni-RCWsWW2vA", + "8z8z9X9jUtb99gjejCwFAGz4aqlHf-sCqQ6eM_qmpUQ", + "Cxq4872UXXngGULT_kl8fdwVFkyK6AJfPZLy7L5_0kI", + "TGf4oLbgwd5JQaHyKVQZU9UdGE0w5rtDsrZzfUaomLo", + "jsu9yVulwQQlhFlM_3JlzMaSFzglhQG0DpfayQwLUK4", + "sFcViHN-JG3eTUyBmU4fkwusy5I1SLBhe1jNvKxP5xM", + "tiTngp9_jhC389UP8_k67MXqoSfiHq3iK6o9un4we_Y", + "xsKkGJXD1-e3I9zj0YyKNv-lU5YqhsEAF9NhOr8xga4" + ], + "iss": "https://example.com/issuer", + "iat": 1683000000, + "exp": 1883000000, + "vct": "https://credentials.example.com/identity_credential", + "_sd_alg": "sha-256", + "cnf": { + "jwk": { + "kty": "EC", + "crv": "P-256", + "x": "TCAER19Zvu3OHF4j4W4vfSVoHIP1ILilDls7vCeGemc", + "y": "ZxjiWWbZMQGHVWKVQ4hbSIirsVfuecCE6t4jT9F2HZQ" + } + } +} +¶ + +The following are disclosures belonging to the claims from the example above.¶ + +Claim given_name:¶ + +SHA-256 Hash: jsu9yVulwQQlhFlM_3JlzMaSFzglhQG0DpfayQwLUK4¶ + + Disclosure: + WyIyR0xDNDJzS1F2ZUNmR2ZyeU5STjl3IiwgImdpdmVuX25hbWUiLCAiSm9o + biJd¶ + + Contents: +["2GLC42sKQveCfGfryNRN9w", "given_name", "John"]¶ + +Claim family_name:¶ + +SHA-256 Hash: TGf4oLbgwd5JQaHyKVQZU9UdGE0w5rtDsrZzfUaomLo¶ + + Disclosure: + WyJlbHVWNU9nM2dTTklJOEVZbnN4QV9BIiwgImZhbWlseV9uYW1lIiwgIkRv + ZSJd¶ + + Contents: +["eluV5Og3gSNII8EYnsxA_A", "family_name", "Doe"]¶ + +Claim birthdate:¶ + +SHA-256 Hash: tiTngp9_jhC389UP8_k67MXqoSfiHq3iK6o9un4we_Y¶ + + Disclosure: + WyI2SWo3dE0tYTVpVlBHYm9TNXRtdlZBIiwgImJpcnRoZGF0ZSIsICIxOTQw + LTAxLTAxIl0¶ + + Contents: +["6Ij7tM-a5iVPGboS5tmvVA", "birthdate", "1940-01-01"]¶ + +B.3.3. Transaction Data + +It is RECOMMENDED that each transaction data type defines a top-level claim parameter to be used in the Key Binding JWT to return the processed transaction data. Additionally, it is RECOMMENDED that it specifies the processing rules, potentially including any hash function to be applied, and the expected resulting structure.¶ + +The transaction data mechanism requires the use of an SD-JWT VC with Cryptographic Holder Binding. Wallets MUST reject requests with transaction data types that have the require_cryptographic_holder_binding parameter set to false.¶ + +B.3.3.1. A Profile of Transaction Data in SD-JWT VC + +The following is one profile that can be included in a transaction data type specification:¶ + + The transaction_data request parameter includes the following parameter, in addition to type and credential_ids from Section 5.1:¶ + + transaction_data_hashes_alg: OPTIONAL. Non-empty array of strings each representing a hash algorithm identifier, one of which MUST be used to calculate hashes in transaction_data_hashes response parameter. The value of the identifier MUST be a hash algorithm value from the "Hash Name String" column in the IANA "Named Information Hash Algorithm" registry [IANA.Hash.Algorithms] or a value defined in another specification and/or profile of this specification. If this parameter is not present, a default value of sha-256 MUST be used. To promote interoperability, implementations MUST support the sha-256 hash algorithm.¶ + + The Key Binding JWT in the response includes the following top-level parameters:¶ + + transaction_data_hashes: A non-empty array of strings where each element is a base64url-encoded hash. Each of these hashes is calculated using a hash function over the string received in the transaction_data request parameter (base64url decoding is not performed before hashing). Each hash value ensures the integrity of, and maps to, the respective transaction data object. If transaction_data_hashes_alg was specified in the request, the hash function MUST be one of its values. If transaction_data_hashes_alg was not specified in the request, the hash function MUST be sha-256.¶ + + transaction_data_hashes_alg: REQUIRED when this parameter was present in the transaction_data request parameter. String representing the hash algorithm identifier used to calculate hashes in transaction_data_hashes response parameter.¶ + +B.3.4. Metadata + +The vp_formats_supported parameter of the Verifier metadata or Wallet metadata MUST have the Credential Format Identifier as a key, and the value MUST be an object consisting of the following name/value pairs:¶ + + sd-jwt_alg_values: OPTIONAL. A non-empty array containing fully-specified identifiers of cryptographic algorithms (as defined in [I-D.ietf-jose-fully-specified-algorithms]) supported for an Issuer-signed JWT of an SD-JWT.¶ + + kb-jwt_alg_values: OPTIONAL. A non-empty array containing fully-specified identifiers of cryptographic algorithms (as defined in [I-D.ietf-jose-fully-specified-algorithms]) supported for a Key Binding JWT (KB-JWT).¶ + +The following is a non-normative example of client_metadata request parameter value in a request to present an IETF SD-JWT VC.¶ + +{ + "vp_formats_supported": { + "dc+sd-jwt": { + "sd-jwt_alg_values": ["ES256", "ES384"], + "kb-jwt_alg_values": ["ES256", "ES384"] + } + } +} +¶ + +B.3.5. Parameter in the meta parameter in Credential Query + +The following is an SD-JWT VC specific parameter in the meta parameter in a Credential Query as defined in Section 6.1.¶ + +vct_values: + REQUIRED. A non-empty array of strings that specifies allowed values for +the type of the requested Verifiable Credential. All elements in the array MUST +be valid type identifiers as defined in [I-D.ietf-oauth-sd-jwt-vc]. The Wallet +MAY return Credentials that inherit from any of the specified types, following +the inheritance logic defined in [I-D.ietf-oauth-sd-jwt-vc].¶ + +B.3.6. Presentation Response + +A non-normative example DCQL query using the SD-JWT VC format is shown in Section 7.4. +The respective response is shown in Section 8.1.1.¶ + +Additional examples are shown in Appendix D.¶ + +The following requirements apply to the nonce and aud claims in the Key Binding JWT:¶ + +the nonce claim MUST be the value of nonce from the Authorization Request;¶ + + the aud claim MUST be the value of the Client Identifier, except for requests over the DC API where it MUST be the Origin prefixed with origin:, as described in Appendix A.4.¶ + +The following is a non-normative example of the unsecured payload of the Key Binding JWT of a Verifiable Presentation.¶ + +{ + "nonce": "n-0S6_WzA2Mj", + "aud": "x509_san_dns:client.example.org", + "iat": 1709838604, + "sd_hash": "Dy-RYwZfaaoC3inJbLslgPvMp09bH-clYP_ diff --git a/linkml/harbour.yaml b/linkml/harbour.yaml index d10bac5..7a85d52 100644 --- a/linkml/harbour.yaml +++ b/linkml/harbour.yaml @@ -103,11 +103,54 @@ slots: description: > A Verifiable Presentation embedded as evidence. In examples this is shown as expanded JSON-LD for readability; on the wire it is encoded - as a VC-JOSE-COSE compact JWS string (typ: vp+ld+jwt). + as a VC-JOSE-COSE compact JWS string (typ: vp+ld+jwt) or SD-JWT VP. slot_uri: harbour:verifiablePresentation range: Any required: false + # --- Delegated Signature Evidence Slots --- + transactionIntent: + description: > + The action or transaction the user is consenting to. Binds consent + to a specific action for auditability. + slot_uri: harbour:transactionIntent + range: TransactionIntent + required: false + + delegatedTo: + description: DID of the signing service executing on behalf of the user. + slot_uri: harbour:delegatedTo + range: uri + required: false + + actionType: + description: > + Type of action being consented to (e.g., "purchase", "transfer", + "sign-contract", "grant-access"). + slot_uri: harbour:actionType + range: string + required: true + + actionReference: + description: > + URI or hash referencing the full transaction details. May be a URN, + IPFS hash, or URL to transaction specification. + slot_uri: harbour:actionReference + range: uri + required: false + + consentTimestamp: + description: Timestamp when the user consented to the action. + slot_uri: harbour:consentTimestamp + range: datetime + required: true + + consentNonce: + description: Unique nonce for replay protection. + slot_uri: harbour:consentNonce + range: string + required: true + # --- Credential Envelope Slots --- issuer: slot_uri: cred:issuer @@ -263,6 +306,46 @@ classes: verifiablePresentation: required: true + DelegatedSignatureEvidence: + is_a: Evidence + description: > + Evidence of user consent for a delegated signature operation. The signing + service executes a transaction on behalf of the user, with the VP serving + as cryptographic proof of consent. For public auditability, the VP should + use SD-JWT format with PII claims redacted. + class_uri: harbour:DelegatedSignatureEvidence + slots: + - verifiablePresentation + - transactionIntent + - delegatedTo + slot_usage: + verifiablePresentation: + required: true + description: > + The consent VP signed by the user. Should be SD-JWT VP format for + privacy-preserving public audit. + transactionIntent: + required: true + + TransactionIntent: + description: > + Describes the action or transaction the user is consenting to. Included + in the VP evidence to bind consent to a specific action for auditability. + class_uri: harbour:TransactionIntent + slots: + - actionType + - actionReference + - description + - consentTimestamp + - consentNonce + slot_usage: + actionType: + required: true + consentTimestamp: + required: true + consentNonce: + required: true + CRSetEntry: class_uri: harbour:CRSetEntry slots: diff --git a/mkdocs.yml b/mkdocs.yml index 5530aef..aeeca49 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -64,9 +64,22 @@ nav: - Getting Started: - Installation: getting-started/installation.md - Quick Start: getting-started/quickstart.md + - Guide: + - Delegated Signing: guide/delegated-signing.md + - Evidence Types: guide/evidence.md + - Specifications: + - Delegation Challenge Encoding: specs/delegation-challenge-encoding.md + - DID Method Evaluation: specs/did-method-evaluation.md + - Reference Specs: specs/references/README.md - CLI Reference: - Overview: cli/index.md - API Reference: - Python: api/python/index.md - TypeScript: api/typescript/index.md - - Architecture Decisions: decisions/ + - Architecture: + - Overview: architecture.md + - ADR-001 VC Securing Mechanism: decisions/001-vc-securing-mechanism.md + - ADR-002 Dual Runtime: decisions/002-dual-runtime-architecture.md + - ADR-003 Canonicalization: decisions/003-canonicalization.md + - ADR-004 Key Management: decisions/004-key-management.md + - Contributing: contributing.md diff --git a/src/python/harbour/delegation.py b/src/python/harbour/delegation.py new file mode 100644 index 0000000..dfdfd46 --- /dev/null +++ b/src/python/harbour/delegation.py @@ -0,0 +1,511 @@ +"""Harbour Delegated Signing Evidence. + +This module implements the Harbour Delegated Signing Evidence Specification v2 +for creating and verifying delegation challenges used in VP proof.challenge fields. + +The challenge format is: HARBOUR_DELEGATE + +Where the hash is computed over a canonical JSON representation of the +transaction data object. + +See docs/specs/delegation-challenge-encoding.md for the full specification. + +CLI Usage: + python -m harbour.delegation --help + python -m harbour.delegation create --action data.purchase --asset-id "urn:uuid:..." --price 100 + python -m harbour.delegation parse "da9b1009 HARBOUR_DELEGATE abc123..." + python -m harbour.delegation display transaction.json + python -m harbour.delegation verify "challenge" transaction.json +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import secrets +import sys +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from typing import Any + +# Action type identifier +ACTION_TYPE = "HARBOUR_DELEGATE" + +# Human-friendly labels for action types +ACTION_LABELS = { + "blockchain.transfer": "Transfer tokens", + "blockchain.approve": "Approve token spending", + "blockchain.execute": "Execute smart contract", + "blockchain.sign": "Sign blockchain message", + "contract.sign": "Sign contract", + "contract.accept": "Accept agreement", + "contract.reject": "Reject agreement", + "data.purchase": "Purchase data asset", + "data.share": "Share data", + "data.access": "Access data", + "credential.issue": "Issue credential", + "credential.revoke": "Revoke credential", + "credential.present": "Present credential", +} + + +class ChallengeError(ValueError): + """Error parsing or validating a delegation challenge.""" + + pass + + +@dataclass +class TransactionData: + """Full transaction data object for delegated signing. + + This object contains all details about the transaction being authorized. + The challenge contains only a hash of this object for compactness. + + Attributes: + action: The action being delegated (e.g., "data.purchase") + timestamp: ISO8601 timestamp when the transaction was created + nonce: Unique identifier for replay protection + transaction: Action-specific transaction details + type: Fixed type identifier (HarbourDelegatedTransaction) + version: Schema version (1.0) + metadata: Optional additional context (description, expiration, etc.) + """ + + action: str + timestamp: str + nonce: str + transaction: dict[str, Any] + type: str = "HarbourDelegatedTransaction" + version: str = "1.0" + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary representation.""" + return asdict(self) + + def to_json(self, canonical: bool = True) -> str: + """Convert to JSON string. + + Args: + canonical: If True, use canonical form (sorted keys, no whitespace) + """ + if canonical: + return json.dumps(self.to_dict(), sort_keys=True, separators=(",", ":")) + return json.dumps(self.to_dict(), indent=2) + + def compute_hash(self) -> str: + """Compute SHA-256 hash of canonical JSON representation. + + Returns: + Lowercase hex-encoded SHA-256 hash (64 characters) + """ + canonical = self.to_json(canonical=True) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> TransactionData: + """Create from dictionary.""" + return cls( + action=data["action"], + timestamp=data["timestamp"], + nonce=data["nonce"], + transaction=data["transaction"], + type=data.get("type", "HarbourDelegatedTransaction"), + version=data.get("version", "1.0"), + metadata=data.get("metadata", {}), + ) + + @classmethod + def from_json(cls, json_str: str) -> TransactionData: + """Create from JSON string.""" + return cls.from_dict(json.loads(json_str)) + + @classmethod + def create( + cls, + action: str, + transaction: dict[str, Any], + *, + nonce: str | None = None, + timestamp: datetime | None = None, + metadata: dict[str, Any] | None = None, + ) -> TransactionData: + """Create a new transaction data object. + + Args: + action: The action being delegated + transaction: Action-specific transaction details + nonce: Unique identifier (auto-generated if not provided) + timestamp: Transaction timestamp (defaults to now) + metadata: Optional additional context + """ + if nonce is None: + nonce = secrets.token_hex(4) # 8 hex characters + + if timestamp is None: + timestamp = datetime.now(timezone.utc) + + return cls( + action=action, + timestamp=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"), + nonce=nonce, + transaction=transaction, + metadata=metadata or {}, + ) + + +def create_delegation_challenge(transaction_data: TransactionData) -> str: + """Create a Harbour delegation challenge string. + + Format: HARBOUR_DELEGATE + + Args: + transaction_data: The full transaction data object + + Returns: + Challenge string suitable for VP proof.challenge field + + Example: + >>> tx = TransactionData.create( + ... action="data.purchase", + ... transaction={"assetId": "urn:uuid:...", "price": "100"}, + ... ) + >>> challenge = create_delegation_challenge(tx) + >>> print(challenge) + da9b1009 HARBOUR_DELEGATE abc123... + """ + tx_hash = transaction_data.compute_hash() + return f"{transaction_data.nonce} {ACTION_TYPE} {tx_hash}" + + +def parse_delegation_challenge(challenge: str) -> tuple[str, str, str]: + """Parse a Harbour delegation challenge string. + + Args: + challenge: The challenge string to parse + + Returns: + Tuple of (nonce, action_type, hash) + + Raises: + ChallengeError: If the challenge format is invalid + + Example: + >>> nonce, action_type, tx_hash = parse_delegation_challenge(challenge) + >>> print(f"Nonce: {nonce}, Hash: {tx_hash[:16]}...") + """ + parts = challenge.split(" ") + if len(parts) != 3: + raise ChallengeError( + f"Invalid challenge format: expected 3 space-separated parts, got {len(parts)}" + ) + + nonce, action_type, tx_hash = parts + + if action_type != ACTION_TYPE: + raise ChallengeError( + f"Invalid action type: expected '{ACTION_TYPE}', got '{action_type}'" + ) + + if len(tx_hash) != 64: + raise ChallengeError( + f"Invalid hash length: expected 64 hex characters, got {len(tx_hash)}" + ) + + # Validate hash is valid hex + try: + int(tx_hash, 16) + except ValueError: + raise ChallengeError("Invalid hash: not valid hexadecimal") + + return nonce, action_type, tx_hash + + +def verify_challenge( + challenge: str, + transaction_data: TransactionData, +) -> bool: + """Verify that a challenge matches transaction data. + + Args: + challenge: The challenge string to verify + transaction_data: The transaction data to verify against + + Returns: + True if the hash in the challenge matches the transaction data + + Example: + >>> if verify_challenge(challenge, tx): + ... print("Challenge is valid!") + """ + nonce, _, challenge_hash = parse_delegation_challenge(challenge) + + if nonce != transaction_data.nonce: + return False + + computed_hash = transaction_data.compute_hash() + return challenge_hash == computed_hash + + +def render_transaction_display( + transaction_data: TransactionData, + service_name: str = "Harbour Signing Service", +) -> str: + """Render transaction data for human-readable display. + + This follows the SIWE (EIP-4361) philosophy of presenting users with + clear, readable consent prompts. + + Args: + transaction_data: The transaction data to display + service_name: Human-friendly name for the signing service + + Returns: + Multi-line string suitable for display to user + """ + action = transaction_data.action + action_label = ACTION_LABELS.get(action, action.replace(".", " ").title()) + + lines = [ + f"{service_name} requests your authorization", + "─" * 50, + "", + f" Action: {action_label}", + ] + + # Add transaction-specific fields + for key, value in transaction_data.transaction.items(): + display_key = key.replace("_", " ").replace("Id", " ID").title() + display_value = str(value) + if len(display_value) > 40: + display_value = display_value[:37] + "..." + lines.append(f" {display_key}: {display_value}") + + lines.extend( + [ + "", + "─" * 50, + f" Nonce: {transaction_data.nonce}", + f" Time: {transaction_data.timestamp}", + ] + ) + + if transaction_data.metadata.get("expiresAt"): + lines.append(f" Expires: {transaction_data.metadata['expiresAt']}") + + if transaction_data.metadata.get("description"): + lines.append(f" Details: {transaction_data.metadata['description']}") + + return "\n".join(lines) + + +def validate_transaction_data( + transaction_data: TransactionData, + *, + max_age_seconds: int = 300, +) -> None: + """Validate transaction data for security requirements. + + Args: + transaction_data: The transaction data to validate + max_age_seconds: Maximum age of the transaction in seconds (default: 5 minutes) + + Raises: + ChallengeError: If validation fails + """ + # Validate type + if transaction_data.type != "HarbourDelegatedTransaction": + raise ChallengeError( + f"Invalid type: expected 'HarbourDelegatedTransaction', got '{transaction_data.type}'" + ) + + # Validate nonce length (minimum 8 hex characters = 32 bits) + if len(transaction_data.nonce) < 8: + raise ChallengeError( + f"Nonce too short: {len(transaction_data.nonce)} chars (minimum 8)" + ) + + # Parse and validate timestamp + try: + ts = datetime.strptime( + transaction_data.timestamp, "%Y-%m-%dT%H:%M:%SZ" + ).replace(tzinfo=timezone.utc) + except ValueError as e: + raise ChallengeError(f"Invalid timestamp format: {e}") from e + + now = datetime.now(timezone.utc) + age = (now - ts).total_seconds() + + if age > max_age_seconds: + raise ChallengeError( + f"Transaction too old: {age:.0f}s (max {max_age_seconds}s)" + ) + + if age < -60: # Allow 1 minute clock skew + raise ChallengeError( + f"Transaction timestamp is in the future: {transaction_data.timestamp}" + ) + + # Check expiration if present + if transaction_data.metadata.get("expiresAt"): + try: + exp = datetime.strptime( + transaction_data.metadata["expiresAt"], "%Y-%m-%dT%H:%M:%SZ" + ).replace(tzinfo=timezone.utc) + except ValueError as e: + raise ChallengeError(f"Invalid expiration format: {e}") from e + + if now > exp: + raise ChallengeError( + f"Transaction expired at {transaction_data.metadata['expiresAt']}" + ) + + +def main(): + """CLI entry point.""" + parser = argparse.ArgumentParser( + prog="harbour.delegation", + description="Harbour Delegated Signing Evidence CLI", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Create a delegation challenge for a data purchase + python -m harbour.delegation create \\ + --action data.purchase \\ + --asset-id "urn:uuid:550e8400-e29b-41d4-a716-446655440000" \\ + --price 100 --currency ENVITED + + # Parse a challenge string + python -m harbour.delegation parse "da9b1009 HARBOUR_DELEGATE abc123..." + + # Display transaction data in human-readable format + python -m harbour.delegation display transaction.json + + # Verify a challenge against transaction data + python -m harbour.delegation verify "da9b1009 HARBOUR_DELEGATE abc123..." transaction.json + """, + ) + + subparsers = parser.add_subparsers(dest="command", help="Commands") + + # Create command + create_parser = subparsers.add_parser( + "create", help="Create a delegation challenge" + ) + create_parser.add_argument( + "--action", required=True, help="Action type (e.g., data.purchase)" + ) + create_parser.add_argument("--asset-id", help="Asset ID for data purchases") + create_parser.add_argument("--price", help="Price/amount") + create_parser.add_argument("--currency", help="Currency/token") + create_parser.add_argument("--chain", help="Blockchain chain ID") + create_parser.add_argument("--contract", help="Contract address") + create_parser.add_argument("--recipient", help="Recipient address") + create_parser.add_argument("--desc", help="Description") + create_parser.add_argument("--exp-minutes", type=int, help="Expiration in minutes") + create_parser.add_argument( + "--output-json", action="store_true", help="Output full JSON" + ) + + # Parse command + parse_parser = subparsers.add_parser("parse", help="Parse a delegation challenge") + parse_parser.add_argument("challenge", help="The challenge string to parse") + + # Display command + display_parser = subparsers.add_parser( + "display", help="Display transaction in human format" + ) + display_parser.add_argument("json_file", help="JSON file with transaction data") + display_parser.add_argument( + "--service", default="Harbour Signing Service", help="Service name" + ) + + # Verify command + verify_parser = subparsers.add_parser("verify", help="Verify a challenge") + verify_parser.add_argument("challenge", help="The challenge string to verify") + verify_parser.add_argument("json_file", help="JSON file with transaction data") + verify_parser.add_argument( + "--max-age", type=int, default=300, help="Max age in seconds" + ) + + args = parser.parse_args() + + if args.command is None: + parser.print_help() + sys.exit(1) + + try: + if args.command == "create": + # Build transaction dict from args + transaction = {} + if args.asset_id: + transaction["assetId"] = args.asset_id + if args.price: + transaction["price"] = args.price + if args.currency: + transaction["currency"] = args.currency + if args.chain: + transaction["chain"] = args.chain + if args.contract: + transaction["contract"] = args.contract + if args.recipient: + transaction["recipient"] = args.recipient + + metadata = {} + if args.desc: + metadata["description"] = args.desc + if args.exp_minutes: + from datetime import timedelta + + exp = datetime.now(timezone.utc) + timedelta(minutes=args.exp_minutes) + metadata["expiresAt"] = exp.strftime("%Y-%m-%dT%H:%M:%SZ") + + tx = TransactionData.create( + action=args.action, + transaction=transaction, + metadata=metadata if metadata else None, + ) + + if args.output_json: + print(tx.to_json(canonical=False)) + else: + challenge = create_delegation_challenge(tx) + print(f"Challenge: {challenge}") + print(f"Hash: {tx.compute_hash()}") + print(f"Nonce: {tx.nonce}") + + elif args.command == "parse": + nonce, action_type, tx_hash = parse_delegation_challenge(args.challenge) + print(f"Nonce: {nonce}") + print(f"Action Type: {action_type}") + print(f"Hash: {tx_hash}") + + elif args.command == "display": + with open(args.json_file, "r") as f: + tx = TransactionData.from_json(f.read()) + print(render_transaction_display(tx, args.service)) + + elif args.command == "verify": + with open(args.json_file, "r") as f: + tx = TransactionData.from_json(f.read()) + + validate_transaction_data(tx, max_age_seconds=args.max_age) + + if verify_challenge(args.challenge, tx): + print("✓ Challenge is valid and matches transaction data") + else: + print("✗ Challenge does not match transaction data", file=sys.stderr) + sys.exit(1) + + except ChallengeError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + except FileNotFoundError as e: + print(f"Error: File not found: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/src/python/harbour/sd_jwt_vp.py b/src/python/harbour/sd_jwt_vp.py new file mode 100644 index 0000000..1f5cb24 --- /dev/null +++ b/src/python/harbour/sd_jwt_vp.py @@ -0,0 +1,490 @@ +"""SD-JWT Verifiable Presentations for privacy-preserving consent. + +This module enables creating VPs where: +- The inner credential is an SD-JWT-VC with selectively disclosed claims +- The VP envelope includes evidence (e.g., DelegatedSignatureEvidence) +- The VP is signed by the holder's key (KB-JWT style binding) + +The SD-JWT VP format follows the IETF SD-JWT specification, extending it for +presentations with evidence. The format is: + + ~~ + +Where: +- vp-jwt: The VP envelope JWT (typ: vp+sd-jwt) +- vc-disclosures: Selected disclosures from the inner SD-JWT-VC +- kb-jwt: Key binding JWT proving holder possession + +CLI Usage: + python -m harbour.sd_jwt_vp --help + python -m harbour.sd_jwt_vp issue --help + python -m harbour.sd_jwt_vp verify --help +""" + +import argparse +import base64 +import hashlib +import json +import sys +import time +from pathlib import Path + +from harbour._crypto import import_private_key as _import_private_key +from harbour._crypto import import_public_key as _import_public_key +from harbour._crypto import load_private_key as _load_private_key +from harbour._crypto import load_public_key as _load_public_key +from harbour._crypto import resolve_private_key_alg as _resolve_alg +from harbour._crypto import resolve_public_key_alg as _alg_for_key +from harbour.keys import PrivateKey, PublicKeyType +from harbour.verifier import VerificationError +from joserfc import jws + +# SD-JWT uses ~-delimited format +SD_JWT_SEPARATOR = "~" + + +def issue_sd_jwt_vp( + sd_jwt_vc: str, + holder_private_key: PrivateKey, + *, + disclosures: list[str] | None = None, + evidence: list[dict] | None = None, + nonce: str | None = None, + audience: str | None = None, + holder_did: str | None = None, +) -> str: + """Issue an SD-JWT VP with selective disclosure and evidence. + + Creates a Verifiable Presentation containing: + - An SD-JWT-VC with selected disclosures (for privacy) + - Evidence objects (e.g., DelegatedSignatureEvidence) + - Key binding proof (holder signature) + + Args: + sd_jwt_vc: The SD-JWT-VC to present (~~...~). + holder_private_key: Holder's private key for VP and KB-JWT signatures. + disclosures: Which disclosures to include (by claim name). + If None, includes all available disclosures. + If empty list [], includes no disclosures (max privacy). + evidence: Evidence objects to include in the VP (e.g., transaction intent). + nonce: Challenge nonce for replay protection. + audience: Intended verifier (DID or URL). + holder_did: Holder's DID for the VP. If not provided, will not be included. + + Returns: + SD-JWT VP string: ~~ + """ + alg = _resolve_alg(holder_private_key, None) + + # Parse the SD-JWT-VC + parts = sd_jwt_vc.split(SD_JWT_SEPARATOR) + if len(parts) < 2: + raise ValueError("Invalid SD-JWT-VC format: missing separator") + + issuer_jwt = parts[0] + # Last element is empty (trailing ~), disclosures are in between + all_disclosures = [p for p in parts[1:] if p] + + # Decode issuer JWT to get _sd digests and claims + issuer_parts = issuer_jwt.split(".") + if len(issuer_parts) != 3: + raise ValueError("Invalid issuer JWT format") + + payload_b64 = issuer_parts[1] + base64.urlsafe_b64decode(payload_b64 + "=" * (-len(payload_b64) % 4)) + + # Build mapping: claim_name -> disclosure_string + disclosure_map = {} + for disc_b64 in all_disclosures: + disc_json = base64.urlsafe_b64decode(disc_b64 + "=" * (-len(disc_b64) % 4)) + disc_array = json.loads(disc_json) + if len(disc_array) == 3: + _, claim_name, _ = disc_array + disclosure_map[claim_name] = disc_b64 + + # Select which disclosures to include + if disclosures is None: + # Include all disclosures + selected_disclosures = list(disclosure_map.values()) + else: + # Include only named disclosures + selected_disclosures = [] + for name in disclosures: + if name in disclosure_map: + selected_disclosures.append(disclosure_map[name]) + + # Build VP payload + vp_payload = { + "vp": { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["VerifiablePresentation"], + }, + "iat": int(time.time()), + } + + if holder_did: + vp_payload["vp"]["holder"] = holder_did + vp_payload["iss"] = holder_did + + if nonce: + vp_payload["nonce"] = nonce + + if audience: + vp_payload["aud"] = audience + + if evidence: + vp_payload["vp"]["evidence"] = evidence + + # Include reference to the VC (the issuer JWT will be reconstructed on verify) + # We store a hash of the issuer JWT for binding + vc_hash = ( + base64.urlsafe_b64encode(hashlib.sha256(issuer_jwt.encode("ascii")).digest()) + .rstrip(b"=") + .decode() + ) + vp_payload["_vc_hash"] = vc_hash + + # Sign VP JWT + vp_header = {"alg": alg, "typ": "vp+sd-jwt"} + vp_payload_bytes = json.dumps(vp_payload, ensure_ascii=False).encode("utf-8") + key = _import_private_key(holder_private_key, alg) + vp_jwt = jws.serialize_compact(vp_header, vp_payload_bytes, key, algorithms=[alg]) + + # Create KB-JWT for holder binding + kb_payload = { + "iat": int(time.time()), + "sd_hash": base64.urlsafe_b64encode( + hashlib.sha256( + ( + issuer_jwt + + SD_JWT_SEPARATOR + + SD_JWT_SEPARATOR.join(selected_disclosures) + ).encode("ascii") + ).digest() + ) + .rstrip(b"=") + .decode(), + } + + if nonce: + kb_payload["nonce"] = nonce + if audience: + kb_payload["aud"] = audience + + kb_header = {"alg": alg, "typ": "kb+jwt"} + kb_payload_bytes = json.dumps(kb_payload, ensure_ascii=False).encode("utf-8") + kb_jwt = jws.serialize_compact(kb_header, kb_payload_bytes, key, algorithms=[alg]) + + # Compose: vp-jwt~issuer-jwt~disc1~disc2~...~kb-jwt + # The issuer JWT is included so verifiers can check the VC + result_parts = [vp_jwt, issuer_jwt] + selected_disclosures + [kb_jwt] + return SD_JWT_SEPARATOR.join(result_parts) + + +def verify_sd_jwt_vp( + sd_jwt_vp: str, + issuer_public_key: PublicKeyType, + holder_public_key: PublicKeyType, + *, + expected_nonce: str | None = None, + expected_audience: str | None = None, +) -> dict: + """Verify an SD-JWT VP and return disclosed claims and evidence. + + Args: + sd_jwt_vp: The SD-JWT VP string to verify. + issuer_public_key: Issuer's public key (for inner VC verification). + holder_public_key: Holder's public key (for VP and KB-JWT verification). + expected_nonce: If provided, verify nonce matches. + expected_audience: If provided, verify audience matches. + + Returns: + dict with: + - 'holder': Holder DID (if present) + - 'credential': Verified credential claims (disclosed only) + - 'evidence': Evidence array (if present) + - 'nonce': Nonce value (if present) + - 'audience': Audience value (if present) + + Raises: + VerificationError: If any verification step fails. + """ + parts = sd_jwt_vp.split(SD_JWT_SEPARATOR) + if len(parts) < 3: + raise VerificationError("Invalid SD-JWT VP format: too few parts") + + vp_jwt = parts[0] + issuer_jwt = parts[1] + kb_jwt = parts[-1] + + # Disclosures are everything between issuer_jwt and kb_jwt + disclosures = parts[2:-1] + + # 1. Verify VP JWT signature (holder) + holder_key = _import_public_key(holder_public_key) + holder_alg = _alg_for_key(holder_public_key) + + try: + vp_result = jws.deserialize_compact(vp_jwt, holder_key, algorithms=[holder_alg]) + except Exception as e: + raise VerificationError(f"VP JWT verification failed: {e}") from e + + vp_header = vp_result.headers() + if vp_header.get("typ") != "vp+sd-jwt": + raise VerificationError( + f"Unexpected VP typ: expected 'vp+sd-jwt', got {vp_header.get('typ')!r}" + ) + + vp_payload = json.loads(vp_result.payload) + + # 2. Verify issuer JWT signature (issuer) + issuer_key = _import_public_key(issuer_public_key) + issuer_alg = _alg_for_key(issuer_public_key) + + try: + vc_result = jws.deserialize_compact( + issuer_jwt, issuer_key, algorithms=[issuer_alg] + ) + except Exception as e: + raise VerificationError(f"VC JWT verification failed: {e}") from e + + vc_header = vc_result.headers() + if vc_header.get("typ") != "vc+sd-jwt": + raise VerificationError( + f"Unexpected VC typ: expected 'vc+sd-jwt', got {vc_header.get('typ')!r}" + ) + + vc_payload = json.loads(vc_result.payload) + + # 3. Verify KB-JWT signature (holder) + try: + kb_result = jws.deserialize_compact(kb_jwt, holder_key, algorithms=[holder_alg]) + except Exception as e: + raise VerificationError(f"KB-JWT verification failed: {e}") from e + + kb_header = kb_result.headers() + if kb_header.get("typ") != "kb+jwt": + raise VerificationError( + f"Unexpected KB-JWT typ: expected 'kb+jwt', got {kb_header.get('typ')!r}" + ) + + kb_payload = json.loads(kb_result.payload) + + # 4. Verify VC hash binding + expected_vc_hash = ( + base64.urlsafe_b64encode(hashlib.sha256(issuer_jwt.encode("ascii")).digest()) + .rstrip(b"=") + .decode() + ) + + if vp_payload.get("_vc_hash") != expected_vc_hash: + raise VerificationError("VC hash mismatch: VP does not bind to presented VC") + + # 5. Verify SD hash in KB-JWT + sd_material = issuer_jwt + SD_JWT_SEPARATOR + SD_JWT_SEPARATOR.join(disclosures) + expected_sd_hash = ( + base64.urlsafe_b64encode(hashlib.sha256(sd_material.encode("ascii")).digest()) + .rstrip(b"=") + .decode() + ) + + if kb_payload.get("sd_hash") != expected_sd_hash: + raise VerificationError("SD hash mismatch in KB-JWT") + + # 6. Verify nonce + if expected_nonce is not None: + if vp_payload.get("nonce") != expected_nonce: + raise VerificationError( + f"Nonce mismatch: expected {expected_nonce!r}, got {vp_payload.get('nonce')!r}" + ) + if kb_payload.get("nonce") != expected_nonce: + raise VerificationError("Nonce mismatch in KB-JWT") + + # 7. Verify audience + if expected_audience is not None: + if vp_payload.get("aud") != expected_audience: + raise VerificationError( + f"Audience mismatch: expected {expected_audience!r}, got {vp_payload.get('aud')!r}" + ) + if kb_payload.get("aud") != expected_audience: + raise VerificationError("Audience mismatch in KB-JWT") + + # 8. Process disclosures + sd_digests = set(vc_payload.get("_sd", [])) + disclosed_claims = { + k: v for k, v in vc_payload.items() if k not in ("_sd", "_sd_alg") + } + + for disc_b64 in disclosures: + disc_hash = ( + base64.urlsafe_b64encode(hashlib.sha256(disc_b64.encode("ascii")).digest()) + .rstrip(b"=") + .decode() + ) + + if disc_hash not in sd_digests: + raise VerificationError( + f"Disclosure hash {disc_hash[:16]}... not found in _sd digests" + ) + sd_digests.discard(disc_hash) + + disc_json = base64.urlsafe_b64decode(disc_b64 + "=" * (-len(disc_b64) % 4)) + disc_array = json.loads(disc_json) + if len(disc_array) != 3: + raise VerificationError( + "Invalid disclosure format: expected [salt, name, value]" + ) + _, claim_name, claim_value = disc_array + disclosed_claims[claim_name] = claim_value + + # Build result + vp_obj = vp_payload.get("vp", {}) + result = { + "credential": disclosed_claims, + } + + if "holder" in vp_obj: + result["holder"] = vp_obj["holder"] + + if "evidence" in vp_obj: + result["evidence"] = vp_obj["evidence"] + + if "nonce" in vp_payload: + result["nonce"] = vp_payload["nonce"] + + if "aud" in vp_payload: + result["audience"] = vp_payload["aud"] + + return result + + +def main(): + """CLI entry point for SD-JWT VP operations.""" + parser = argparse.ArgumentParser( + prog="harbour.sd_jwt_vp", + description="Harbour SD-JWT VP CLI - Issue and verify SD-JWT Verifiable Presentations", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Issue an SD-JWT VP with selective disclosure + python -m harbour.sd_jwt_vp issue --sd-jwt-vc vc.txt --key holder-key.jwk \\ + --disclosures memberOf --nonce abc123 --audience did:web:verifier.example.com + + # Issue with evidence (DelegatedSignatureEvidence) + python -m harbour.sd_jwt_vp issue --sd-jwt-vc vc.txt --key holder-key.jwk \\ + --evidence evidence.json --nonce abc123 + + # Verify an SD-JWT VP + python -m harbour.sd_jwt_vp verify --sd-jwt-vp vp.txt \\ + --issuer-key issuer-pub.jwk --holder-key holder-pub.jwk + """, + ) + + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # Issue subcommand + issue_parser = subparsers.add_parser( + "issue", + help="Issue an SD-JWT VP", + description="Create an SD-JWT Verifiable Presentation with selective disclosure.", + ) + issue_parser.add_argument( + "--sd-jwt-vc", required=True, help="File containing the SD-JWT-VC to present" + ) + issue_parser.add_argument( + "--key", required=True, help="Holder's private key (JWK file)" + ) + issue_parser.add_argument( + "--disclosures", + nargs="*", + help="Claim names to disclose (default: all). Use empty for none.", + ) + issue_parser.add_argument("--evidence", help="JSON file with evidence objects") + issue_parser.add_argument("--nonce", help="Challenge nonce for replay protection") + issue_parser.add_argument("--audience", help="Intended verifier (DID or URL)") + issue_parser.add_argument("--holder-did", help="Holder's DID") + issue_parser.add_argument("--output", "-o", help="Output file (default: stdout)") + + # Verify subcommand + verify_parser = subparsers.add_parser( + "verify", + help="Verify an SD-JWT VP", + description="Verify an SD-JWT Verifiable Presentation.", + ) + verify_parser.add_argument( + "--sd-jwt-vp", required=True, help="File containing the SD-JWT VP to verify" + ) + verify_parser.add_argument( + "--issuer-key", required=True, help="Issuer's public key (JWK file)" + ) + verify_parser.add_argument( + "--holder-key", required=True, help="Holder's public key (JWK file)" + ) + verify_parser.add_argument("--nonce", help="Expected nonce") + verify_parser.add_argument("--audience", help="Expected audience") + + args = parser.parse_args() + + if args.command is None: + parser.print_help() + sys.exit(1) + + if args.command == "issue": + # Load SD-JWT-VC + sd_jwt_vc = Path(args.sd_jwt_vc).read_text().strip() + + # Load holder private key + private_key, _ = _load_private_key(args.key) + + # Load evidence if provided + evidence = None + if args.evidence: + evidence = json.loads(Path(args.evidence).read_text()) + if not isinstance(evidence, list): + evidence = [evidence] + + # Determine disclosures + disclosures = args.disclosures # None means all, [] means none + + # Issue VP + vp = issue_sd_jwt_vp( + sd_jwt_vc, + private_key, + disclosures=disclosures, + evidence=evidence, + nonce=args.nonce, + audience=args.audience, + holder_did=args.holder_did, + ) + + # Output + if args.output: + Path(args.output).write_text(vp + "\n") + print(f"SD-JWT VP written to {args.output}", file=sys.stderr) + else: + print(vp) + + elif args.command == "verify": + # Load SD-JWT VP + sd_jwt_vp = Path(args.sd_jwt_vp).read_text().strip() + + # Load keys + issuer_public_key = _load_public_key(args.issuer_key) + holder_public_key = _load_public_key(args.holder_key) + + try: + result = verify_sd_jwt_vp( + sd_jwt_vp, + issuer_public_key, + holder_public_key, + expected_nonce=args.nonce, + expected_audience=args.audience, + ) + print(json.dumps(result, indent=2)) + except VerificationError as e: + print(f"Verification failed: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/python/harbour/test_delegation.py b/tests/python/harbour/test_delegation.py new file mode 100644 index 0000000..b6bf954 --- /dev/null +++ b/tests/python/harbour/test_delegation.py @@ -0,0 +1,820 @@ +"""Tests for harbour.delegation module. + +This module tests the Harbour Delegated Signing Evidence Specification v2. + +Tests cover: +- TransactionData creation and serialization +- Challenge creation and parsing +- Hash computation determinism +- Challenge verification +- Validation (timestamp, nonce, expiration) +- Human-readable display rendering +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +import pytest +from harbour.delegation import ( + ACTION_LABELS, + ACTION_TYPE, + ChallengeError, + TransactionData, + create_delegation_challenge, + parse_delegation_challenge, + render_transaction_display, + validate_transaction_data, + verify_challenge, +) + +# ============================================================================= +# TransactionData Tests +# ============================================================================= + + +class TestTransactionData: + """Tests for TransactionData dataclass.""" + + def test_create_basic(self): + """Test basic TransactionData creation.""" + tx = TransactionData.create( + action="data.purchase", + transaction={"assetId": "urn:uuid:test", "price": "100"}, + ) + + assert tx.action == "data.purchase" + assert tx.type == "HarbourDelegatedTransaction" + assert tx.version == "1.0" + assert tx.transaction == {"assetId": "urn:uuid:test", "price": "100"} + assert tx.metadata == {} + assert len(tx.nonce) == 8 # Default hex nonce is 8 chars + assert tx.timestamp.endswith("Z") + + def test_create_with_custom_nonce(self): + """Test TransactionData with custom nonce.""" + tx = TransactionData.create( + action="data.purchase", + transaction={"assetId": "test"}, + nonce="custom123", + ) + + assert tx.nonce == "custom123" + + def test_create_with_custom_timestamp(self): + """Test TransactionData with custom timestamp.""" + ts = datetime(2026, 2, 24, 12, 0, 0, tzinfo=timezone.utc) + tx = TransactionData.create( + action="data.purchase", + transaction={"assetId": "test"}, + timestamp=ts, + ) + + assert tx.timestamp == "2026-02-24T12:00:00Z" + + def test_create_with_metadata(self): + """Test TransactionData with metadata.""" + tx = TransactionData.create( + action="data.purchase", + transaction={"assetId": "test"}, + metadata={ + "description": "Test purchase", + "expiresAt": "2026-02-24T13:00:00Z", + }, + ) + + assert tx.metadata["description"] == "Test purchase" + assert tx.metadata["expiresAt"] == "2026-02-24T13:00:00Z" + + def test_to_dict(self): + """Test TransactionData.to_dict().""" + tx = TransactionData( + action="data.purchase", + timestamp="2026-02-24T12:00:00Z", + nonce="da9b1009", + transaction={"assetId": "test", "price": "100"}, + ) + + d = tx.to_dict() + + assert d["type"] == "HarbourDelegatedTransaction" + assert d["version"] == "1.0" + assert d["action"] == "data.purchase" + assert d["timestamp"] == "2026-02-24T12:00:00Z" + assert d["nonce"] == "da9b1009" + assert d["transaction"] == {"assetId": "test", "price": "100"} + assert d["metadata"] == {} + + def test_to_json_canonical(self): + """Test canonical JSON output (sorted keys, no whitespace).""" + tx = TransactionData( + action="data.purchase", + timestamp="2026-02-24T12:00:00Z", + nonce="da9b1009", + transaction={"zzzField": "last", "aaaField": "first"}, + ) + + json_str = tx.to_json(canonical=True) + + # Verify no whitespace + assert " " not in json_str + assert "\n" not in json_str + + # Verify sorted keys (aaaField before zzzField) + assert json_str.index("aaaField") < json_str.index("zzzField") + + def test_to_json_pretty(self): + """Test pretty JSON output.""" + tx = TransactionData( + action="data.purchase", + timestamp="2026-02-24T12:00:00Z", + nonce="da9b1009", + transaction={"assetId": "test"}, + ) + + json_str = tx.to_json(canonical=False) + + # Verify indentation + assert "\n" in json_str + assert " " in json_str + + def test_from_dict(self): + """Test TransactionData.from_dict().""" + data = { + "type": "HarbourDelegatedTransaction", + "version": "1.0", + "action": "contract.sign", + "timestamp": "2026-02-24T12:00:00Z", + "nonce": "ab12cd34", + "transaction": {"documentHash": "sha256:abc123"}, + "metadata": {"expiresAt": "2026-02-24T13:00:00Z"}, + } + + tx = TransactionData.from_dict(data) + + assert tx.type == "HarbourDelegatedTransaction" + assert tx.version == "1.0" + assert tx.action == "contract.sign" + assert tx.nonce == "ab12cd34" + assert tx.transaction["documentHash"] == "sha256:abc123" + assert tx.metadata["expiresAt"] == "2026-02-24T13:00:00Z" + + def test_from_json(self): + """Test TransactionData.from_json().""" + json_str = '{"action":"data.purchase","nonce":"abc12345","timestamp":"2026-02-24T12:00:00Z","transaction":{"assetId":"test"},"type":"HarbourDelegatedTransaction","version":"1.0"}' + + tx = TransactionData.from_json(json_str) + + assert tx.action == "data.purchase" + assert tx.nonce == "abc12345" + + def test_round_trip(self): + """Test serialization round-trip preserves data.""" + original = TransactionData.create( + action="blockchain.transfer", + transaction={"recipient": "0xabc", "amount": "1000"}, + metadata={"description": "Test transfer"}, + ) + + # Round-trip through JSON + json_str = original.to_json(canonical=True) + restored = TransactionData.from_json(json_str) + + assert restored.action == original.action + assert restored.nonce == original.nonce + assert restored.timestamp == original.timestamp + assert restored.transaction == original.transaction + assert restored.metadata == original.metadata + + +class TestHashComputation: + """Tests for hash computation.""" + + def test_compute_hash_deterministic(self): + """Test that hash computation is deterministic.""" + tx1 = TransactionData( + action="data.purchase", + timestamp="2026-02-24T12:00:00Z", + nonce="da9b1009", + transaction={"assetId": "test", "price": "100"}, + ) + + tx2 = TransactionData( + action="data.purchase", + timestamp="2026-02-24T12:00:00Z", + nonce="da9b1009", + transaction={"assetId": "test", "price": "100"}, + ) + + assert tx1.compute_hash() == tx2.compute_hash() + + def test_compute_hash_key_order_independent(self): + """Test that hash is independent of transaction dict key order.""" + tx1 = TransactionData( + action="data.purchase", + timestamp="2026-02-24T12:00:00Z", + nonce="da9b1009", + transaction={"assetId": "test", "price": "100"}, + ) + + tx2 = TransactionData( + action="data.purchase", + timestamp="2026-02-24T12:00:00Z", + nonce="da9b1009", + transaction={"price": "100", "assetId": "test"}, # Different order + ) + + # Hashes should be equal since canonical JSON sorts keys + assert tx1.compute_hash() == tx2.compute_hash() + + def test_compute_hash_64_hex_chars(self): + """Test that hash is 64 hex characters (SHA-256).""" + tx = TransactionData.create( + action="data.purchase", + transaction={"assetId": "test"}, + ) + + hash_value = tx.compute_hash() + + assert len(hash_value) == 64 + assert all(c in "0123456789abcdef" for c in hash_value) + + def test_compute_hash_changes_with_data(self): + """Test that hash changes when data changes.""" + tx1 = TransactionData( + action="data.purchase", + timestamp="2026-02-24T12:00:00Z", + nonce="da9b1009", + transaction={"assetId": "test", "price": "100"}, + ) + + tx2 = TransactionData( + action="data.purchase", + timestamp="2026-02-24T12:00:00Z", + nonce="da9b1009", + transaction={"assetId": "test", "price": "200"}, # Different price + ) + + assert tx1.compute_hash() != tx2.compute_hash() + + def test_compute_hash_sensitive_to_all_fields(self): + """Test that hash changes for any field change.""" + base = { + "action": "data.purchase", + "timestamp": "2026-02-24T12:00:00Z", + "nonce": "da9b1009", + "transaction": {"assetId": "test"}, + } + + base_tx = TransactionData(**base) + base_hash = base_tx.compute_hash() + + # Test each field change produces different hash + variations = [ + {"action": "data.share"}, + {"timestamp": "2026-02-24T13:00:00Z"}, + {"nonce": "different"}, + {"transaction": {"assetId": "other"}}, + ] + + for change in variations: + modified = {**base, **change} + modified_tx = TransactionData(**modified) + assert ( + modified_tx.compute_hash() != base_hash + ), f"Hash unchanged for {change}" + + +# ============================================================================= +# Challenge Creation Tests +# ============================================================================= + + +class TestCreateDelegationChallenge: + """Tests for create_delegation_challenge().""" + + def test_basic_challenge(self): + """Test basic challenge creation.""" + tx = TransactionData( + action="data.purchase", + timestamp="2026-02-24T12:00:00Z", + nonce="da9b1009", + transaction={"assetId": "test", "price": "100"}, + ) + + challenge = create_delegation_challenge(tx) + parts = challenge.split(" ") + + assert len(parts) == 3 + assert parts[0] == "da9b1009" # nonce + assert parts[1] == "HARBOUR_DELEGATE" # action type + assert len(parts[2]) == 64 # SHA-256 hash + + def test_challenge_matches_hash(self): + """Test that challenge hash matches computed hash.""" + tx = TransactionData( + action="data.purchase", + timestamp="2026-02-24T12:00:00Z", + nonce="da9b1009", + transaction={"assetId": "test"}, + ) + + challenge = create_delegation_challenge(tx) + _, _, challenge_hash = challenge.split(" ") + + assert challenge_hash == tx.compute_hash() + + +# ============================================================================= +# Challenge Parsing Tests +# ============================================================================= + + +class TestParseDelegationChallenge: + """Tests for parse_delegation_challenge().""" + + def test_parse_valid_challenge(self): + """Test parsing a valid challenge.""" + challenge = "da9b1009 HARBOUR_DELEGATE " + "a" * 64 + + nonce, action_type, tx_hash = parse_delegation_challenge(challenge) + + assert nonce == "da9b1009" + assert action_type == "HARBOUR_DELEGATE" + assert tx_hash == "a" * 64 + + def test_parse_invalid_part_count(self): + """Test that invalid part count raises error.""" + with pytest.raises(ChallengeError) as excinfo: + parse_delegation_challenge("only") + + assert "expected 3" in str(excinfo.value) + + def test_parse_invalid_action_type(self): + """Test that invalid action type raises error.""" + challenge = "da9b1009 WRONG_ACTION " + "a" * 64 + + with pytest.raises(ChallengeError) as excinfo: + parse_delegation_challenge(challenge) + + assert "Invalid action type" in str(excinfo.value) + + def test_parse_invalid_hash_length(self): + """Test that invalid hash length raises error.""" + challenge = "da9b1009 HARBOUR_DELEGATE tooshort" + + with pytest.raises(ChallengeError) as excinfo: + parse_delegation_challenge(challenge) + + assert "Invalid hash length" in str(excinfo.value) + + def test_parse_invalid_hash_hex(self): + """Test that non-hex hash raises error.""" + challenge = "da9b1009 HARBOUR_DELEGATE " + "g" * 64 # 'g' is not valid hex + + with pytest.raises(ChallengeError) as excinfo: + parse_delegation_challenge(challenge) + + assert "not valid hexadecimal" in str(excinfo.value) + + def test_round_trip(self): + """Test create -> parse round-trip.""" + tx = TransactionData.create( + action="data.purchase", + transaction={"assetId": "test"}, + ) + + challenge = create_delegation_challenge(tx) + nonce, action_type, tx_hash = parse_delegation_challenge(challenge) + + assert nonce == tx.nonce + assert action_type == ACTION_TYPE + assert tx_hash == tx.compute_hash() + + +# ============================================================================= +# Challenge Verification Tests +# ============================================================================= + + +class TestVerifyChallenge: + """Tests for verify_challenge().""" + + def test_verify_matching_challenge(self): + """Test verification of matching challenge.""" + tx = TransactionData( + action="data.purchase", + timestamp="2026-02-24T12:00:00Z", + nonce="da9b1009", + transaction={"assetId": "test"}, + ) + + challenge = create_delegation_challenge(tx) + + assert verify_challenge(challenge, tx) is True + + def test_verify_mismatched_nonce(self): + """Test verification fails for mismatched nonce.""" + tx = TransactionData( + action="data.purchase", + timestamp="2026-02-24T12:00:00Z", + nonce="da9b1009", + transaction={"assetId": "test"}, + ) + + # Create challenge with different nonce + challenge = "different " + f"HARBOUR_DELEGATE {tx.compute_hash()}" + + assert verify_challenge(challenge, tx) is False + + def test_verify_mismatched_hash(self): + """Test verification fails for mismatched hash.""" + tx = TransactionData( + action="data.purchase", + timestamp="2026-02-24T12:00:00Z", + nonce="da9b1009", + transaction={"assetId": "test"}, + ) + + # Create challenge with wrong hash + challenge = "da9b1009 HARBOUR_DELEGATE " + "b" * 64 + + assert verify_challenge(challenge, tx) is False + + def test_verify_tampered_data(self): + """Test verification fails for tampered transaction data.""" + tx = TransactionData( + action="data.purchase", + timestamp="2026-02-24T12:00:00Z", + nonce="da9b1009", + transaction={"assetId": "test", "price": "100"}, + ) + + challenge = create_delegation_challenge(tx) + + # Tamper with transaction data + tx.transaction["price"] = "999" + + assert verify_challenge(challenge, tx) is False + + +# ============================================================================= +# Transaction Validation Tests +# ============================================================================= + + +class TestValidateTransactionData: + """Tests for validate_transaction_data().""" + + def test_validate_valid_transaction(self): + """Test validation of valid transaction.""" + tx = TransactionData.create( + action="data.purchase", + transaction={"assetId": "test"}, + ) + + # Should not raise + validate_transaction_data(tx) + + def test_validate_invalid_type(self): + """Test validation fails for invalid type.""" + tx = TransactionData( + action="data.purchase", + timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + nonce="da9b1009", + transaction={"assetId": "test"}, + type="WrongType", + ) + + with pytest.raises(ChallengeError) as excinfo: + validate_transaction_data(tx) + + assert "Invalid type" in str(excinfo.value) + + def test_validate_short_nonce(self): + """Test validation fails for short nonce.""" + tx = TransactionData( + action="data.purchase", + timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + nonce="abc", # Too short (< 8 chars) + transaction={"assetId": "test"}, + ) + + with pytest.raises(ChallengeError) as excinfo: + validate_transaction_data(tx) + + assert "Nonce too short" in str(excinfo.value) + + def test_validate_old_timestamp(self): + """Test validation fails for old timestamp.""" + old_time = datetime.now(timezone.utc) - timedelta(minutes=10) + tx = TransactionData( + action="data.purchase", + timestamp=old_time.strftime("%Y-%m-%dT%H:%M:%SZ"), + nonce="da9b1009", + transaction={"assetId": "test"}, + ) + + with pytest.raises(ChallengeError) as excinfo: + validate_transaction_data(tx, max_age_seconds=300) + + assert "Transaction too old" in str(excinfo.value) + + def test_validate_future_timestamp(self): + """Test validation fails for future timestamp.""" + future_time = datetime.now(timezone.utc) + timedelta(minutes=5) + tx = TransactionData( + action="data.purchase", + timestamp=future_time.strftime("%Y-%m-%dT%H:%M:%SZ"), + nonce="da9b1009", + transaction={"assetId": "test"}, + ) + + with pytest.raises(ChallengeError) as excinfo: + validate_transaction_data(tx) + + assert "future" in str(excinfo.value) + + def test_validate_expired_transaction(self): + """Test validation fails for expired transaction.""" + past_expiry = datetime.now(timezone.utc) - timedelta(minutes=5) + tx = TransactionData.create( + action="data.purchase", + transaction={"assetId": "test"}, + metadata={"expiresAt": past_expiry.strftime("%Y-%m-%dT%H:%M:%SZ")}, + ) + + with pytest.raises(ChallengeError) as excinfo: + validate_transaction_data(tx) + + assert "expired" in str(excinfo.value) + + def test_validate_custom_max_age(self): + """Test validation with custom max age.""" + # 2 minutes old + old_time = datetime.now(timezone.utc) - timedelta(seconds=120) + tx = TransactionData( + action="data.purchase", + timestamp=old_time.strftime("%Y-%m-%dT%H:%M:%SZ"), + nonce="da9b1009", + transaction={"assetId": "test"}, + ) + + # Should fail with 60s max age + with pytest.raises(ChallengeError): + validate_transaction_data(tx, max_age_seconds=60) + + # Should pass with 300s max age + validate_transaction_data(tx, max_age_seconds=300) + + +# ============================================================================= +# Human Display Tests +# ============================================================================= + + +class TestRenderTransactionDisplay: + """Tests for render_transaction_display().""" + + def test_render_basic(self): + """Test basic display rendering.""" + tx = TransactionData( + action="data.purchase", + timestamp="2026-02-24T12:00:00Z", + nonce="da9b1009", + transaction={ + "assetId": "urn:uuid:test", + "price": "100", + "currency": "ENVITED", + }, + ) + + display = render_transaction_display(tx) + + assert "requests your authorization" in display + assert "Purchase data asset" in display # Human-readable label + assert "da9b1009" in display + assert "2026-02-24T12:00:00Z" in display + + def test_render_custom_service_name(self): + """Test display with custom service name.""" + tx = TransactionData.create( + action="data.purchase", + transaction={"assetId": "test"}, + ) + + display = render_transaction_display(tx, service_name="Custom Service") + + assert "Custom Service requests your authorization" in display + + def test_render_unknown_action(self): + """Test display with unknown action type.""" + tx = TransactionData( + action="unknown.action", + timestamp="2026-02-24T12:00:00Z", + nonce="da9b1009", + transaction={"someField": "value"}, + ) + + display = render_transaction_display(tx) + + # Should convert "unknown.action" to "Unknown Action" + assert "Unknown Action" in display + + def test_render_with_expiration(self): + """Test display includes expiration if present.""" + tx = TransactionData.create( + action="data.purchase", + transaction={"assetId": "test"}, + metadata={"expiresAt": "2026-02-24T13:00:00Z"}, + ) + + display = render_transaction_display(tx) + + assert "Expires:" in display + assert "2026-02-24T13:00:00Z" in display + + def test_render_with_description(self): + """Test display includes description if present.""" + tx = TransactionData.create( + action="data.purchase", + transaction={"assetId": "test"}, + metadata={"description": "Purchase sensor data from BMW"}, + ) + + display = render_transaction_display(tx) + + assert "Details:" in display + assert "Purchase sensor data from BMW" in display + + def test_render_truncates_long_values(self): + """Test display truncates very long values.""" + tx = TransactionData.create( + action="data.purchase", + transaction={"assetId": "a" * 100}, # Very long value + ) + + display = render_transaction_display(tx) + + # Should be truncated with ellipsis + assert "..." in display + + def test_render_all_action_labels(self): + """Test that all known action labels are rendered correctly.""" + for action, label in ACTION_LABELS.items(): + tx = TransactionData.create( + action=action, + transaction={"testField": "value"}, + ) + + display = render_transaction_display(tx) + + assert label in display, f"Label '{label}' not found for action '{action}'" + + +# ============================================================================= +# CLI Tests +# ============================================================================= + + +class TestCLI: + """Tests for CLI functionality.""" + + def test_main_create_command(self, capsys): + """Test CLI create command.""" + import sys + + from harbour.delegation import main + + with patch.object( + sys, + "argv", + [ + "delegation", + "create", + "--action", + "data.purchase", + "--asset-id", + "urn:uuid:test", + "--price", + "100", + ], + ): + main() + + captured = capsys.readouterr() + assert "Challenge:" in captured.out + assert "HARBOUR_DELEGATE" in captured.out + + def test_main_parse_command(self, capsys): + """Test CLI parse command.""" + import sys + + from harbour.delegation import main + + challenge = "da9b1009 HARBOUR_DELEGATE " + "a" * 64 + + with patch.object(sys, "argv", ["delegation", "parse", challenge]): + main() + + captured = capsys.readouterr() + assert "Nonce: da9b1009" in captured.out + assert "Action Type: HARBOUR_DELEGATE" in captured.out + + def test_main_no_command_shows_help(self, capsys): + """Test CLI with no command shows help.""" + import sys + + from harbour.delegation import main + + with patch.object(sys, "argv", ["delegation"]): + with pytest.raises(SystemExit) as excinfo: + main() + + assert excinfo.value.code == 1 + + def test_main_parse_invalid_challenge(self, capsys): + """Test CLI parse with invalid challenge exits with error.""" + import sys + + from harbour.delegation import main + + with patch.object(sys, "argv", ["delegation", "parse", "invalid"]): + with pytest.raises(SystemExit) as excinfo: + main() + + assert excinfo.value.code == 1 + captured = capsys.readouterr() + assert "Error:" in captured.err + + +# ============================================================================= +# Integration Tests +# ============================================================================= + + +class TestIntegration: + """Integration tests for the full delegation workflow.""" + + def test_full_workflow(self): + """Test complete delegation workflow.""" + # 1. Create transaction data + tx = TransactionData.create( + action="data.purchase", + transaction={ + "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "price": "100", + "currency": "ENVITED", + }, + metadata={"description": "Purchase sensor data"}, + ) + + # 2. Create challenge + challenge = create_delegation_challenge(tx) + + # 3. Parse challenge + nonce, action_type, tx_hash = parse_delegation_challenge(challenge) + + assert nonce == tx.nonce + assert action_type == "HARBOUR_DELEGATE" + + # 4. Verify challenge matches transaction data + assert verify_challenge(challenge, tx) + + # 5. Validate transaction data + validate_transaction_data(tx) + + # 6. Render for human display + display = render_transaction_display(tx) + assert "Purchase data asset" in display + + def test_serialization_workflow(self): + """Test serialization/deserialization in workflow.""" + # Create and serialize + original_tx = TransactionData.create( + action="contract.sign", + transaction={"documentHash": "sha256:abc123"}, + ) + challenge = create_delegation_challenge(original_tx) + tx_json = original_tx.to_json() + + # Simulate transmission: deserialize + restored_tx = TransactionData.from_json(tx_json) + + # Verify challenge against restored data + assert verify_challenge(challenge, restored_tx) + + def test_multiple_transactions_unique_hashes(self): + """Test that multiple transactions produce unique hashes.""" + hashes = set() + + for i in range(10): + tx = TransactionData.create( + action="data.purchase", + transaction={"assetId": f"asset-{i}"}, + ) + hashes.add(tx.compute_hash()) + + # All hashes should be unique + assert len(hashes) == 10 diff --git a/tests/python/harbour/test_sd_jwt_vp.py b/tests/python/harbour/test_sd_jwt_vp.py new file mode 100644 index 0000000..184e4d3 --- /dev/null +++ b/tests/python/harbour/test_sd_jwt_vp.py @@ -0,0 +1,581 @@ +"""Tests for SD-JWT VP (Verifiable Presentations with selective disclosure).""" + +import base64 +import json +import secrets + +import pytest +from harbour.keys import generate_p256_keypair, p256_public_key_to_did_key +from harbour.sd_jwt import issue_sd_jwt_vc +from harbour.sd_jwt_vp import issue_sd_jwt_vp, verify_sd_jwt_vp +from harbour.verifier import VerificationError + + +@pytest.fixture +def issuer_keypair(): + """Generate issuer key pair.""" + return generate_p256_keypair() + + +@pytest.fixture +def holder_keypair(): + """Generate holder key pair.""" + return generate_p256_keypair() + + +@pytest.fixture +def sample_sd_jwt_vc(issuer_keypair, holder_keypair): + """Create a sample SD-JWT-VC for testing.""" + private_key, public_key = issuer_keypair + holder_private, holder_public = holder_keypair + holder_did = p256_public_key_to_did_key(holder_public) + + credential = { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://www.w3.org/ns/credentials/examples/v2", + ], + "type": ["VerifiableCredential", "MembershipCredential"], + "issuer": "did:web:issuer.example.com", + "credentialSubject": { + "id": holder_did, + "givenName": "Alice", + "familyName": "Smith", + "email": "alice@example.com", + "memberOf": "Example Organization", + "role": "member", + }, + } + + # Create SD-JWT-VC with selective disclosure claims + sd_jwt_vc = issue_sd_jwt_vc( + credential, + private_key, + sd_claims=["givenName", "familyName", "email"], + ) + + return sd_jwt_vc + + +class TestIssueSDJWTVP: + """Test SD-JWT VP issuance.""" + + def test_issue_basic_vp(self, sample_sd_jwt_vc, holder_keypair): + """Test basic VP issuance with all disclosures.""" + holder_private, _ = holder_keypair + + vp = issue_sd_jwt_vp( + sample_sd_jwt_vc, + holder_private, + nonce="test-nonce-123", + audience="did:web:verifier.example.com", + ) + + # Should be a ~-separated string + assert "~" in vp + parts = vp.split("~") + + # Should have: vp-jwt, issuer-jwt, disclosures..., kb-jwt + assert len(parts) >= 4 + + # First part should be VP JWT + vp_jwt = parts[0] + header_b64 = vp_jwt.split(".")[0] + header = json.loads(base64.urlsafe_b64decode(header_b64 + "==")) + assert header["typ"] == "vp+sd-jwt" + assert header["alg"] == "ES256" + + # Last part should be KB-JWT + kb_jwt = parts[-1] + kb_header_b64 = kb_jwt.split(".")[0] + kb_header = json.loads(base64.urlsafe_b64decode(kb_header_b64 + "==")) + assert kb_header["typ"] == "kb+jwt" + + def test_issue_with_selective_disclosure(self, sample_sd_jwt_vc, holder_keypair): + """Test VP issuance with selective disclosure (only some claims).""" + holder_private, _ = holder_keypair + + # Only disclose memberOf, hide PII + vp = issue_sd_jwt_vp( + sample_sd_jwt_vc, + holder_private, + disclosures=["memberOf"], + nonce="nonce-456", + ) + + parts = vp.split("~") + # Should have fewer disclosures than full + # vp-jwt + issuer-jwt + 1 disclosure + kb-jwt = 4 parts + # But memberOf is not an SD claim, so 0 disclosures included + assert len(parts) >= 3 + + def test_issue_with_no_disclosures(self, sample_sd_jwt_vc, holder_keypair): + """Test VP issuance with no disclosures (max privacy).""" + holder_private, _ = holder_keypair + + vp = issue_sd_jwt_vp( + sample_sd_jwt_vc, + holder_private, + disclosures=[], # Empty list = no disclosures + nonce="nonce-789", + ) + + parts = vp.split("~") + # Should have: vp-jwt, issuer-jwt, kb-jwt (no disclosures) + assert len(parts) >= 3 + + def test_issue_with_evidence(self, sample_sd_jwt_vc, holder_keypair): + """Test VP issuance with DelegatedSignatureEvidence.""" + holder_private, _ = holder_keypair + + evidence = [ + { + "type": "DelegatedSignatureEvidence", + "transactionIntent": { + "actionType": "purchase", + "actionReference": "tx:abc123", + "consentTimestamp": "2024-01-15T10:30:00Z", + "consentNonce": secrets.token_urlsafe(16), + }, + "delegatedTo": "did:web:signing-service.example.com", + } + ] + + vp = issue_sd_jwt_vp( + sample_sd_jwt_vc, + holder_private, + evidence=evidence, + nonce="tx-consent-nonce", + audience="did:web:signing-service.example.com", + ) + + # Parse VP JWT payload to check evidence + parts = vp.split("~") + vp_jwt = parts[0] + payload_b64 = vp_jwt.split(".")[1] + payload = json.loads( + base64.urlsafe_b64decode(payload_b64 + "=" * (-len(payload_b64) % 4)) + ) + + assert "vp" in payload + assert "evidence" in payload["vp"] + assert len(payload["vp"]["evidence"]) == 1 + assert payload["vp"]["evidence"][0]["type"] == "DelegatedSignatureEvidence" + + def test_issue_with_holder_did(self, sample_sd_jwt_vc, holder_keypair): + """Test VP issuance with holder DID.""" + holder_private, holder_public = holder_keypair + holder_did = p256_public_key_to_did_key(holder_public) + + vp = issue_sd_jwt_vp( + sample_sd_jwt_vc, + holder_private, + holder_did=holder_did, + nonce="holder-nonce", + ) + + # Parse VP JWT payload + parts = vp.split("~") + vp_jwt = parts[0] + payload_b64 = vp_jwt.split(".")[1] + payload = json.loads( + base64.urlsafe_b64decode(payload_b64 + "=" * (-len(payload_b64) % 4)) + ) + + assert payload.get("iss") == holder_did + assert payload["vp"].get("holder") == holder_did + + +class TestVerifySDJWTVP: + """Test SD-JWT VP verification.""" + + def test_verify_basic_vp(self, sample_sd_jwt_vc, issuer_keypair, holder_keypair): + """Test basic VP verification.""" + _, issuer_public = issuer_keypair + holder_private, holder_public = holder_keypair + + nonce = "verify-test-nonce" + audience = "did:web:verifier.example.com" + + vp = issue_sd_jwt_vp( + sample_sd_jwt_vc, + holder_private, + nonce=nonce, + audience=audience, + ) + + result = verify_sd_jwt_vp( + vp, + issuer_public, + holder_public, + expected_nonce=nonce, + expected_audience=audience, + ) + + assert "credential" in result + assert result["nonce"] == nonce + assert result["audience"] == audience + + def test_verify_disclosed_claims( + self, sample_sd_jwt_vc, issuer_keypair, holder_keypair + ): + """Test that disclosed claims are returned.""" + _, issuer_public = issuer_keypair + holder_private, holder_public = holder_keypair + + # Include all disclosures + vp = issue_sd_jwt_vp( + sample_sd_jwt_vc, + holder_private, + ) + + result = verify_sd_jwt_vp(vp, issuer_public, holder_public) + + cred = result["credential"] + # Non-SD claims should always be present + assert cred.get("memberOf") == "Example Organization" + assert cred.get("role") == "member" + # SD claims should be disclosed + assert cred.get("givenName") == "Alice" + assert cred.get("familyName") == "Smith" + assert cred.get("email") == "alice@example.com" + + def test_verify_selective_disclosure( + self, sample_sd_jwt_vc, issuer_keypair, holder_keypair + ): + """Test verification with partial disclosure.""" + _, issuer_public = issuer_keypair + holder_private, holder_public = holder_keypair + + # Only disclose givenName + vp = issue_sd_jwt_vp( + sample_sd_jwt_vc, + holder_private, + disclosures=["givenName"], + ) + + result = verify_sd_jwt_vp(vp, issuer_public, holder_public) + + cred = result["credential"] + # Disclosed claim should be present + assert cred.get("givenName") == "Alice" + # Other SD claims should NOT be present + assert "familyName" not in cred + assert "email" not in cred + + def test_verify_evidence(self, sample_sd_jwt_vc, issuer_keypair, holder_keypair): + """Test that evidence is returned on verification.""" + _, issuer_public = issuer_keypair + holder_private, holder_public = holder_keypair + + evidence = [ + { + "type": "DelegatedSignatureEvidence", + "transactionIntent": { + "actionType": "approve", + "consentTimestamp": "2024-01-15T12:00:00Z", + "consentNonce": "unique-consent-nonce", + }, + } + ] + + vp = issue_sd_jwt_vp( + sample_sd_jwt_vc, + holder_private, + evidence=evidence, + ) + + result = verify_sd_jwt_vp(vp, issuer_public, holder_public) + + assert "evidence" in result + assert len(result["evidence"]) == 1 + assert result["evidence"][0]["type"] == "DelegatedSignatureEvidence" + + def test_verify_fails_wrong_issuer_key(self, sample_sd_jwt_vc, holder_keypair): + """Test that verification fails with wrong issuer key.""" + holder_private, holder_public = holder_keypair + _, wrong_issuer_public = generate_p256_keypair() + + vp = issue_sd_jwt_vp(sample_sd_jwt_vc, holder_private) + + with pytest.raises(VerificationError, match="VC JWT verification failed"): + verify_sd_jwt_vp(vp, wrong_issuer_public, holder_public) + + def test_verify_fails_wrong_holder_key( + self, sample_sd_jwt_vc, issuer_keypair, holder_keypair + ): + """Test that verification fails with wrong holder key.""" + _, issuer_public = issuer_keypair + holder_private, _ = holder_keypair + _, wrong_holder_public = generate_p256_keypair() + + vp = issue_sd_jwt_vp(sample_sd_jwt_vc, holder_private) + + with pytest.raises(VerificationError, match="VP JWT verification failed"): + verify_sd_jwt_vp(vp, issuer_public, wrong_holder_public) + + def test_verify_fails_nonce_mismatch( + self, sample_sd_jwt_vc, issuer_keypair, holder_keypair + ): + """Test that verification fails with nonce mismatch.""" + _, issuer_public = issuer_keypair + holder_private, holder_public = holder_keypair + + vp = issue_sd_jwt_vp( + sample_sd_jwt_vc, + holder_private, + nonce="original-nonce", + ) + + with pytest.raises(VerificationError, match="Nonce mismatch"): + verify_sd_jwt_vp( + vp, + issuer_public, + holder_public, + expected_nonce="wrong-nonce", + ) + + def test_verify_fails_audience_mismatch( + self, sample_sd_jwt_vc, issuer_keypair, holder_keypair + ): + """Test that verification fails with audience mismatch.""" + _, issuer_public = issuer_keypair + holder_private, holder_public = holder_keypair + + vp = issue_sd_jwt_vp( + sample_sd_jwt_vc, + holder_private, + audience="did:web:expected-verifier.example.com", + ) + + with pytest.raises(VerificationError, match="Audience mismatch"): + verify_sd_jwt_vp( + vp, + issuer_public, + holder_public, + expected_audience="did:web:wrong-verifier.example.com", + ) + + +class TestDelegatedSigningFlow: + """Test the complete delegated signing flow.""" + + def test_delegated_consent_flow(self, issuer_keypair, holder_keypair): + """Test the full delegated signing consent flow. + + This simulates: + 1. Issuer issues SD-JWT-VC to holder + 2. Holder creates VP with transaction consent evidence + 3. Signing service verifies VP and evidence + """ + issuer_private, issuer_public = issuer_keypair + holder_private, holder_public = holder_keypair + holder_did = p256_public_key_to_did_key(holder_public) + + # Step 1: Issue credential to holder + credential = { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["VerifiableCredential", "IdentityCredential"], + "issuer": "did:web:trusted-issuer.example.com", + "credentialSubject": { + "id": holder_did, + "givenName": "Carlo", + "familyName": "Rossi", + "organization": "BMW", + "role": "Purchaser", + }, + } + + sd_jwt_vc = issue_sd_jwt_vc( + credential, + issuer_private, + sd_claims=["givenName", "familyName"], # PII is selectively disclosable + ) + + # Step 2: Holder creates consent VP + signing_service_did = "did:web:harbour.signing-service.example.com" + consent_nonce = secrets.token_urlsafe(32) + + transaction_intent = { + "actionType": "blockchain:purchase", + "actionReference": "tx:0xabc123def456", + "consentTimestamp": "2024-01-15T14:30:00Z", + "consentNonce": consent_nonce, + "description": "Purchase data asset XYZ for 100 ENVITED tokens", + } + + evidence = [ + { + "type": "DelegatedSignatureEvidence", + "transactionIntent": transaction_intent, + "delegatedTo": signing_service_did, + } + ] + + challenge_nonce = secrets.token_urlsafe(16) + + # Create VP with: + # - Only organization and role disclosed (not PII) + # - Evidence containing transaction intent + vp = issue_sd_jwt_vp( + sd_jwt_vc, + holder_private, + disclosures=["organization"], # Don't disclose givenName, familyName + evidence=evidence, + nonce=challenge_nonce, + audience=signing_service_did, + holder_did=holder_did, + ) + + # Step 3: Signing service verifies VP + result = verify_sd_jwt_vp( + vp, + issuer_public, + holder_public, + expected_nonce=challenge_nonce, + expected_audience=signing_service_did, + ) + + # Verify result + assert result["holder"] == holder_did + assert result["nonce"] == challenge_nonce + assert result["audience"] == signing_service_did + + # Credential should have organization but NOT PII + cred = result["credential"] + assert cred.get("organization") == "BMW" + assert "givenName" not in cred # PII hidden + assert "familyName" not in cred # PII hidden + + # Evidence should contain transaction intent + assert len(result["evidence"]) == 1 + ev = result["evidence"][0] + assert ev["type"] == "DelegatedSignatureEvidence" + assert ev["transactionIntent"]["actionType"] == "blockchain:purchase" + assert ev["transactionIntent"]["consentNonce"] == consent_nonce + assert ev["delegatedTo"] == signing_service_did + + def test_public_audit_privacy(self, issuer_keypair, holder_keypair): + """Test that public audit can verify consent without seeing PII.""" + issuer_private, issuer_public = issuer_keypair + holder_private, holder_public = holder_keypair + holder_did = p256_public_key_to_did_key(holder_public) + + # Issue credential with PII + credential = { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["VerifiableCredential"], + "issuer": "did:web:issuer.example.com", + "credentialSubject": { + "id": holder_did, + "name": "Confidential Person", + "email": "secret@example.com", + "publicRole": "Authorized Purchaser", + }, + } + + sd_jwt_vc = issue_sd_jwt_vc( + credential, + issuer_private, + sd_claims=["name", "email"], # PII hidden by default + ) + + # Create VP with no PII disclosed + evidence = [ + { + "type": "DelegatedSignatureEvidence", + "transactionIntent": { + "actionType": "execute:transfer", + "actionReference": "blockchain:tx:0x123", + "consentTimestamp": "2024-01-15T15:00:00Z", + "consentNonce": "public-audit-nonce", + }, + } + ] + + vp = issue_sd_jwt_vp( + sd_jwt_vc, + holder_private, + disclosures=["publicRole"], # Only non-PII disclosed + evidence=evidence, + holder_did=holder_did, + ) + + # Public auditor verifies + result = verify_sd_jwt_vp(vp, issuer_public, holder_public) + + # Can verify consent happened + assert result["evidence"][0]["type"] == "DelegatedSignatureEvidence" + + # Can see authorized role + assert result["credential"]["publicRole"] == "Authorized Purchaser" + + # Cannot see PII + assert "name" not in result["credential"] + assert "email" not in result["credential"] + + # DID is visible for audit trail + assert result["holder"] == holder_did + + +class TestEdgeCases: + """Test edge cases and error handling.""" + + def test_invalid_sd_jwt_vc_format(self, holder_keypair): + """Test handling of invalid SD-JWT-VC format.""" + holder_private, _ = holder_keypair + + with pytest.raises(ValueError, match="Invalid SD-JWT-VC format"): + issue_sd_jwt_vp("not-a-valid-sd-jwt", holder_private) + + def test_invalid_sd_jwt_vp_format(self, issuer_keypair, holder_keypair): + """Test handling of invalid SD-JWT VP format.""" + _, issuer_public = issuer_keypair + _, holder_public = holder_keypair + + with pytest.raises(VerificationError, match="Invalid SD-JWT VP format"): + verify_sd_jwt_vp("not~valid", issuer_public, holder_public) + + def test_empty_evidence_list(self, sample_sd_jwt_vc, holder_keypair): + """Test VP with empty evidence list.""" + holder_private, _ = holder_keypair + + vp = issue_sd_jwt_vp( + sample_sd_jwt_vc, + holder_private, + evidence=[], # Empty but not None + ) + + # Should work but evidence field still present + parts = vp.split("~") + vp_jwt = parts[0] + payload_b64 = vp_jwt.split(".")[1] + payload = json.loads( + base64.urlsafe_b64decode(payload_b64 + "=" * (-len(payload_b64) % 4)) + ) + + assert payload["vp"]["evidence"] == [] + + def test_multiple_evidence_items(self, sample_sd_jwt_vc, holder_keypair): + """Test VP with multiple evidence items.""" + holder_private, _ = holder_keypair + + evidence = [ + {"type": "DelegatedSignatureEvidence", "transactionIntent": {}}, + {"type": "EmailVerification", "verifiedEmail": "test@example.com"}, + ] + + vp = issue_sd_jwt_vp( + sample_sd_jwt_vc, + holder_private, + evidence=evidence, + ) + + parts = vp.split("~") + vp_jwt = parts[0] + payload_b64 = vp_jwt.split(".")[1] + payload = json.loads( + base64.urlsafe_b64decode(payload_b64 + "=" * (-len(payload_b64) % 4)) + ) + + assert len(payload["vp"]["evidence"]) == 2 From fa44078340728dc9d47fc4ba1c2a301e1c8f58f3 Mon Sep 17 00:00:00 2001 From: jdsika Date: Wed, 25 Feb 2026 09:54:25 +0100 Subject: [PATCH 02/78] feat(delegation): align evidence model with OID4VP and add TypeScript delegation modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the evidence model for W3C-correct, OID4VP-aligned credentials with three-layer privacy (public / authorized / full audit). Schema changes (LinkML): - Merge EmailVerification + IssuanceEvidence → CredentialEvidence - Simplify DelegatedSignatureEvidence to use OID4VP transactionData - Remove TransactionIntent class and legacy slots Python: - Refactor delegation.py TransactionData to OID4VP §8.4 fields (type, credential_ids, iat/exp as Unix timestamps, txn, nonce) - Fix pre-existing SD-JWT VP test bugs (wrong parameter names, missing vct) TypeScript: - Add delegation.ts with full feature parity (challenge, hash, display) - Add sd-jwt-vp.ts for SD-JWT VP issue/verify with evidence - Add cross-runtime canonicalization interop tests (5 tests) Examples: - Add delegated-signing-receipt.json and consent-vp.json - Update evidence types in existing examples Housekeeping: - Update CLAUDE.md module table and key imports for new modules - Expose delegation and sd_jwt_vp in harbour __init__.py - Use pathlib.Path in delegation.py CLI (per coding conventions) - Fix duplicate section numbering in delegation spec 293 Python + 97 TypeScript + 11 interop tests — all passing. Signed-off-by: jdsika --- CLAUDE.md | 6 + docs/guide/delegated-signing.md | 206 +++++--- docs/guide/evidence.md | 99 ++-- docs/specs/delegation-challenge-encoding.md | 413 ++++------------ examples/consent-vp.json | 41 ++ examples/delegated-signing-receipt.json | 65 +++ examples/legal-person-credential.json | 2 +- examples/natural-person-credential.json | 2 +- linkml/harbour.yaml | 93 +--- src/python/credentials/example_signer.py | 8 +- src/python/harbour/__init__.py | 27 ++ src/python/harbour/delegation.py | 209 ++++---- src/python/harbour/sd_jwt_vp.py | 4 +- src/typescript/harbour/delegation.ts | 332 +++++++++++++ src/typescript/harbour/index.ts | 25 + src/typescript/harbour/sd-jwt-vp.ts | 363 ++++++++++++++ tests/fixtures/canonicalization-vectors.json | 62 +++ tests/interop/test_cross_runtime.py | 122 +++++ .../python/credentials/test_claim_mapping.py | 2 +- .../python/credentials/test_example_signer.py | 35 +- tests/python/credentials/test_validation.py | 22 +- tests/python/harbour/test_delegation.py | 408 ++++++++++------ tests/python/harbour/test_sd_jwt_vp.py | 140 +++--- tests/typescript/harbour/delegation.test.ts | 447 ++++++++++++++++++ tests/typescript/harbour/sd-jwt-vp.test.ts | 272 +++++++++++ 25 files changed, 2572 insertions(+), 833 deletions(-) create mode 100644 examples/consent-vp.json create mode 100644 examples/delegated-signing-receipt.json create mode 100644 src/typescript/harbour/delegation.ts create mode 100644 src/typescript/harbour/sd-jwt-vp.ts create mode 100644 tests/fixtures/canonicalization-vectors.json create mode 100644 tests/typescript/harbour/delegation.test.ts create mode 100644 tests/typescript/harbour/sd-jwt-vp.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8c5a23e..d54360d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,6 +65,8 @@ Python (`src/python/harbour/`) and TypeScript (`src/typescript/harbour/`) implem | `verifier` / `verify` | `verifier.py` | `verify.ts` | JWT verification | | `sd_jwt` / `sd-jwt` | `sd_jwt.py` | `sd-jwt.ts` | SD-JWT-VC selective disclosure | | `kb_jwt` / `kb-jwt` | `kb_jwt.py` | `kb-jwt.ts` | Key Binding JWT | +| `delegation` | `delegation.py` | `delegation.ts` | Delegated signing evidence (OID4VP) | +| `sd_jwt_vp` / `sd-jwt-vp` | `sd_jwt_vp.py` | `sd-jwt-vp.ts` | SD-JWT VP issue/verify with evidence | | `x509` | `x509.py` | `x509.ts` | X.509 certificates | | `credentials/` | Python only | — | Credential processing pipeline | @@ -107,6 +109,8 @@ from harbour.signer import sign_vc_jose from harbour.verifier import verify_vc_jose, VerificationError from harbour.sd_jwt import issue_sd_jwt_vc, verify_sd_jwt_vc from harbour.kb_jwt import create_kb_jwt, verify_kb_jwt +from harbour.delegation import TransactionData, create_delegation_challenge, verify_challenge +from harbour.sd_jwt_vp import issue_sd_jwt_vp, verify_sd_jwt_vp from harbour.x509 import generate_self_signed_cert, validate_x5c_chain ``` @@ -118,6 +122,8 @@ import { signJwt, verifyJwt, issueSdJwt, verifySdJwt, createKbJwt, verifyKbJwt, + createDelegationChallenge, verifyChallenge, createTransactionData, + issueSdJwtVp, verifySdJwtVp, } from '@reachhaven/harbour-credentials'; ``` diff --git a/docs/guide/delegated-signing.md b/docs/guide/delegated-signing.md index d738a38..9035289 100644 --- a/docs/guide/delegated-signing.md +++ b/docs/guide/delegated-signing.md @@ -24,31 +24,33 @@ The key innovation is **cryptographic proof of consent** — the user's VP serve ``` User Signing Service Blockchain - │ │ │ - │ 1. Request transaction │ │ - │ ─────────────────────► │ │ - │ │ │ - │ 2. Consent request │ │ - │ ◄───────────────────── │ │ - │ (transaction details, │ │ - │ nonce) │ │ - │ │ │ - │ 3. Create SD-JWT VP │ │ - │ (consent proof with │ │ - │ redacted PII) │ │ - │ ─────────────────────► │ │ - │ │ │ - │ │ 4. Verify VP │ - │ │ ✓ Signature valid │ - │ │ ✓ Credential valid │ - │ │ ✓ Intent matches │ - │ │ │ - │ │ 5. Execute transaction │ - │ │ ─────────────────────► │ - │ │ │ - │ │ 6. Store VP as evidence │ - │ │ (for audit) │ - │ │ │ + | | | + | 1. Request transaction | | + | ─────────────────────► | | + | | | + | 2. Consent request | | + | ◄───────────────────── | | + | (OID4VP transaction_data,| | + | nonce, audience) | | + | | | + | 3. Create SD-JWT VP | | + | (consent proof with | | + | KB-JWT binding to | | + | transaction_data_hash) | | + | ─────────────────────► | | + | | | + | | 4. Verify VP | + | | ✓ Signature valid | + | | ✓ Credential valid | + | | ✓ Transaction matches | + | | | + | | 5. Execute transaction | + | | ─────────────────────► | + | | | + | | 6. Issue receipt VC | + | | (DelegatedSignature- | + | | Evidence + CRSet) | + | | | ``` ## User Setup @@ -94,13 +96,34 @@ The user's DID document (`did:web:carlo.simpulse.io`) must contain a verificatio } ``` +## OID4VP Transaction Data + +The signing service creates an OID4VP-aligned transaction data object (see [Delegation Challenge Encoding](../specs/delegation-challenge-encoding.md)): + +```json +{ + "type": "harbour_delegate:data.purchase", + "credential_ids": ["simpulse_id"], + "transaction_data_hashes_alg": ["sha-256"], + "nonce": "da9b1009", + "iat": 1771934400, + "txn": { + "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "price": "100", + "currency": "ENVITED", + "marketplace": "did:web:dataspace.envited.io" + } +} +``` + ## Creating the Consent VP When the signing service requests consent, the user creates an **SD-JWT VP** with: 1. **Selective disclosure**: Only non-PII claims disclosed -2. **Evidence**: Transaction intent proving what was consented to -3. **Signature**: Signed with the user's P-256 key +2. **Evidence**: Transaction data proving what was consented to +3. **KB-JWT**: Bound to the transaction data hash +4. **Signature**: Signed with the user's P-256 key ### Python Example @@ -110,18 +133,21 @@ from harbour.sd_jwt_vp import issue_sd_jwt_vp # User's SD-JWT-VC (with all disclosures) sd_jwt_vc = "eyJ...~disclosure1~disclosure2~..." -# Transaction intent (what the user is consenting to) +# Transaction evidence (OID4VP-aligned) evidence = [{ - "type": "harbour:DelegatedSignatureEvidence", - "transactionIntent": { - "type": "harbour:TransactionIntent", - "actionType": "purchase", - "actionReference": "urn:uuid:tx-12345", - "description": "Purchase 'Weather Data 2024' for €500", - "consentTimestamp": "2024-01-15T10:30:00Z", - "nonce": "abc123xyz" + "type": "DelegatedSignatureEvidence", + "transactionData": { + "type": "harbour_delegate:data.purchase", + "credential_ids": ["simpulse_id"], + "nonce": "da9b1009", + "iat": 1771934400, + "txn": { + "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "price": "100", + "currency": "ENVITED" + } }, - "delegatedTo": "did:web:signing-service.harbour.io" + "delegatedTo": "did:web:signing-service.envited.io" }] # Create VP with selective disclosure (redact PII) @@ -130,8 +156,8 @@ sd_jwt_vp = issue_sd_jwt_vp( holder_private_key, disclosures=["memberOf"], # Only disclose non-PII claims evidence=evidence, - nonce="abc123xyz", - audience="did:web:signing-service.harbour.io" + nonce="da9b1009", + audience="did:web:signing-service.envited.io" ) ``` @@ -143,19 +169,22 @@ import { issueSdJwtVp } from '@reachhaven/harbour-credentials'; const sdJwtVp = await issueSdJwtVp(sdJwtVc, holderPrivateKey, { disclosures: ['memberOf'], evidence: [{ - type: 'harbour:DelegatedSignatureEvidence', - transactionIntent: { - type: 'harbour:TransactionIntent', - actionType: 'purchase', - actionReference: 'urn:uuid:tx-12345', - description: "Purchase 'Weather Data 2024' for €500", - consentTimestamp: '2024-01-15T10:30:00Z', - nonce: 'abc123xyz' + type: 'DelegatedSignatureEvidence', + transactionData: { + type: 'harbour_delegate:data.purchase', + credential_ids: ['simpulse_id'], + nonce: 'da9b1009', + iat: 1771934400, + txn: { + assetId: 'urn:uuid:550e8400-e29b-41d4-a716-446655440000', + price: '100', + currency: 'ENVITED' + } }, - delegatedTo: 'did:web:signing-service.harbour.io' + delegatedTo: 'did:web:signing-service.envited.io' }], - nonce: 'abc123xyz', - audience: 'did:web:signing-service.harbour.io' + nonce: 'da9b1009', + audience: 'did:web:signing-service.envited.io' }); ``` @@ -170,46 +199,66 @@ result = verify_sd_jwt_vp( sd_jwt_vp, issuer_public_key, # From credential issuer's DID holder_public_key, # From user's DID document - expected_nonce="abc123xyz", - expected_audience="did:web:signing-service.harbour.io" + expected_nonce="da9b1009", + expected_audience="did:web:signing-service.envited.io" ) -# Check transaction intent matches original request -assert result["evidence"][0]["transactionIntent"]["actionReference"] == "urn:uuid:tx-12345" +# Check transaction data matches original request +tx = result["evidence"][0]["transactionData"] +assert tx["type"] == "harbour_delegate:data.purchase" +assert tx["txn"]["assetId"] == "urn:uuid:550e8400-e29b-41d4-a716-446655440000" # Check credential is still valid (CRSet) # ... revocation check ... -# All checks pass → execute transaction +# All checks pass -> execute transaction ``` -## Privacy Model +## Receipt Credential -The SD-JWT VP enables **privacy-preserving audit**: +After executing the transaction, the signing service issues a **receipt credential** (SD-JWT-VC) with `DelegatedSignatureEvidence`: -| Data | Public Audit | Private Audit | -|------|--------------|---------------| -| Transaction intent | ✅ Visible | ✅ Visible | -| User DID | ✅ Visible | ✅ Visible | -| VP signature | ✅ Verifiable | ✅ Verifiable | -| Credential validity | ✅ Via CRSet | ✅ Via CRSet | -| User name | ❌ Redacted | ✅ Available | -| User email | ❌ Redacted | ✅ Available | +```json +{ + "type": ["VerifiableCredential", "harbour:DelegatedSigningReceipt"], + "issuer": "did:web:signing-service.envited.io", + "evidence": [{ + "type": "harbour:DelegatedSignatureEvidence", + "verifiablePresentation": "", + "delegatedTo": "did:web:signing-service.envited.io", + "transactionData": { "..." } + }], + "credentialStatus": [{ + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + }] +} +``` + +The receipt credential enables three-layer privacy via selective disclosure (see [Evidence](evidence.md#three-layer-privacy-model)). + +## Privacy Model -**Public audit** proves: -> "The holder of `did:web:carlo.simpulse.io` consented to transaction `tx-12345` at `2024-01-15T10:30:00Z`" +The SD-JWT VP enables **three-layer privacy-preserving audit**: -**Private audit** (with additional disclosures) proves: -> "Carlo Rossi (carlo@bmw.de), member of BMW, consented to..." +| Data | Layer 1 (Public) | Layer 2 (Authorized) | Layer 3 (Full Audit) | +|------|:-:|:-:|:-:| +| CRSet entry (credential exists) | Yes | Yes | Yes | +| Transaction data hash on-chain | Yes | Yes | Yes | +| KB-JWT signature valid | Yes | Yes | Yes | +| Transaction details (asset, price) | No | Yes | Yes | +| Consent VP hash verification | No | Yes | Yes | +| User name | No | No | Yes | +| User email | No | No | Yes | ## Security Considerations ### Replay Protection -The `nonce` in `TransactionIntent` prevents replay attacks: +The `nonce` in transaction data prevents replay attacks: - Signing service generates unique nonce per request -- VP must contain matching nonce +- VP must contain matching nonce in KB-JWT - Nonce is single-use ### Audience Binding @@ -220,7 +269,7 @@ The `audience` field ensures the VP was created for a specific verifier: verify_sd_jwt_vp( vp, ..., - expected_audience="did:web:signing-service.harbour.io" + expected_audience="did:web:signing-service.envited.io" ) ``` @@ -258,33 +307,34 @@ verify_sd_jwt_vp(vp, issuer_key, public_key_from_did_doc, ...) User purchases dataset through blockchain: 1. User browses marketplace, selects dataset -2. App requests consent: "Purchase 'Weather Data 2024' for €500?" +2. App creates OID4VP transaction data: "Purchase 'Weather Data 2024' for 100 ENVITED" 3. User creates consent VP with wallet 4. Harbour executes blockchain transaction -5. VP stored as purchase receipt/evidence +5. Receipt credential issued with `DelegatedSignatureEvidence` ### Contract Signing User signs legal contract: 1. Contract platform prepares document -2. Requests signature: "Sign employment contract with BMW?" +2. Creates transaction data: `harbour_delegate:contract.sign` 3. User creates consent VP 4. Harbour records signature on blockchain -5. VP serves as proof of signing intent +5. Receipt VP serves as proof of signing intent ### Access Delegation User grants access to resource: -1. Service requests access: "Grant read access to Project X?" +1. Service creates transaction data: `harbour_delegate:data.access` 2. User creates consent VP 3. Harbour updates access control on blockchain -4. VP serves as access grant evidence +4. Receipt VP serves as access grant evidence ## Related Documentation - [Evidence Types](evidence.md) — All Harbour evidence types +- [Delegation Challenge Encoding](../specs/delegation-challenge-encoding.md) — OID4VP transaction data spec - [SD-JWT-VC](../api/python/index.md) — SD-JWT credential issuance - [ADR-001: VC Securing Mechanism](../decisions/001-vc-securing-mechanism.md) — Why SD-JWT - [ADR-004: Key Management](../decisions/004-key-management.md) — P-256 keys diff --git a/docs/guide/evidence.md b/docs/guide/evidence.md index b9baa48..a210532 100644 --- a/docs/guide/evidence.md +++ b/docs/guide/evidence.md @@ -13,15 +13,15 @@ Evidence creates an **audit trail** — allowing third parties to verify not jus ## Harbour Evidence Types -### EmailVerification +### CredentialEvidence -Proves that an email address was verified before credential issuance. +Proves that the issuer verified claims using a prior credential or verifiable presentation. The embedded VP contains the credentials the issuer relied upon (e.g., email verification, notary attestation). -**Use case**: A `NaturalPersonCredential` includes evidence that the user's email was verified via an email verification service (e.g., Altme EmailPass). +**Use case (email verification)**: A `NaturalPersonCredential` includes evidence that the user's email was verified via an email verification service (e.g., Altme EmailPass). ```json { - "type": "harbour:EmailVerification", + "type": "harbour:CredentialEvidence", "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], @@ -40,17 +40,11 @@ Proves that an email address was verified before credential issuance. } ``` -**What it proves**: The issuer verified the email address via a trusted email verification provider before issuing the credential. - -### IssuanceEvidence - -References a previously issued credential that served as the basis for the new credential. - -**Use case**: A `LegalPersonCredential` includes evidence of a prior credential from a notary attesting to the organization's registration. +**Use case (notary attestation)**: A `LegalPersonCredential` includes evidence of a prior credential from a notary attesting to the organization's registration. ```json { - "type": "harbour:IssuanceEvidence", + "type": "harbour:CredentialEvidence", "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], @@ -70,41 +64,55 @@ References a previously issued credential that served as the basis for the new c } ``` -**What it proves**: The issuer based the credential on a prior attestation from another trusted party (the notary). +**What it proves**: The issuer based the credential on a prior attestation from another trusted party. ### DelegatedSignatureEvidence -Proves user consent for a delegated signature operation. Used when a signing service executes transactions on behalf of users. +Evidence on a **receipt credential** (SD-JWT-VC) that a signing service executed a transaction with the user's explicit consent. The consent VP uses SD-JWT with PII redacted. Transaction data is a disclosable claim enabling three-layer privacy (public / authorized / full audit). -**Use case**: A blockchain transaction record includes evidence that the user consented to the purchase. +**Use case**: A signing service issues a receipt credential after executing a blockchain purchase on behalf of a user. ```json { "type": "harbour:DelegatedSignatureEvidence", - "transactionIntent": { - "type": "harbour:TransactionIntent", - "actionType": "purchase", - "actionReference": "urn:uuid:tx-12345", - "description": "Purchase 'Weather Data 2024' for €500", - "consentTimestamp": "2024-01-15T10:30:00Z", - "nonce": "abc123xyz" - }, - "delegatedTo": "did:web:signing-service.harbour.io", - "verifiablePresentation": "" + "verifiablePresentation": "", + "delegatedTo": "did:web:signing-service.envited.io", + "transactionData": { + "type": "harbour_delegate:data.purchase", + "credential_ids": ["simpulse_id"], + "transaction_data_hashes_alg": ["sha-256"], + "nonce": "da9b1009", + "iat": 1771934400, + "txn": { + "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "price": "100", + "currency": "ENVITED", + "marketplace": "did:web:dataspace.envited.io" + } + } } ``` -**What it proves**: The user (identified by their DID) explicitly consented to the specific transaction at the specified time. +**What it proves**: The user explicitly consented to the specific transaction, and the signing service executed it on their behalf. See [Delegated Signing](delegated-signing.md) for the complete flow. +## Three-Layer Privacy Model + +The receipt credential is an **SD-JWT-VC**. Transaction data and identity details are **selectively disclosable**: + +| Layer | Audience | What's Visible | +|-------|----------|----------------| +| **Layer 1 — Public** | Everyone | CRSet entry (credential exists), transaction_data_hash on-chain, DID identifier, KB-JWT signature valid | +| **Layer 2 — Authorized** | Auditor | Transaction details (asset, price, marketplace), consent VP hash verification | +| **Layer 3 — Full Audit** | Compliance | User identity (name, email, organization), full credential chain | + ## When to Use Each Type | Evidence Type | Use When | Example Scenario | |--------------|----------|------------------| -| `EmailVerification` | Issuing credential that includes email claim | Onboarding a new user, verifying contact info | -| `IssuanceEvidence` | Basing credential on prior attestation | Trust anchor issuing based on notary credential | -| `DelegatedSignatureEvidence` | User consenting to delegated action | Blockchain purchase, contract signing | +| `CredentialEvidence` | Issuing credential based on prior attestation | Email verification, notary credential, identity proofing | +| `DelegatedSignatureEvidence` | Issuing receipt after delegated action | Blockchain purchase, contract signing, access delegation | ## Evidence Structure @@ -113,7 +121,7 @@ All evidence types inherit from the abstract `Evidence` class and share: ```yaml Evidence: abstract: true - class_uri: cred:evidence + class_uri: cred:Evidence slots: - type # Required: identifies the evidence type ``` @@ -126,9 +134,10 @@ Evidence often contains sensitive information. For privacy-preserving audit: 1. **Use SD-JWT VPs**: Selectively disclose only necessary claims 2. **Redact PII**: Names, emails, etc. can be hidden while keeping DID visible -3. **Public vs. Private audit**: - - Public: Transaction intent + DID + signature validity - - Private: Full credential details with all claims +3. **Three-layer disclosure**: + - Public: CRSet + transaction hash + signature validity + - Authorized: Transaction details (asset, price) + - Full audit: Identity details (name, email, organization) ## Verification @@ -165,7 +174,7 @@ credential = { "credentialSubject": {...}, "evidence": [ { - "type": "harbour:EmailVerification", + "type": "harbour:CredentialEvidence", "verifiablePresentation": email_verification_vp_jwt } ] @@ -185,25 +194,29 @@ Evidence: slots: - type -EmailVerification: - is_a: Evidence - class_uri: harbour:EmailVerification - slots: - - verifiablePresentation - -IssuanceEvidence: +CredentialEvidence: is_a: Evidence - class_uri: harbour:IssuanceEvidence + class_uri: harbour:CredentialEvidence slots: - verifiablePresentation + slot_usage: + verifiablePresentation: + required: true DelegatedSignatureEvidence: is_a: Evidence class_uri: harbour:DelegatedSignatureEvidence slots: - verifiablePresentation - - transactionIntent - delegatedTo + - transactionData + slot_usage: + verifiablePresentation: + required: true + delegatedTo: + required: true + transactionData: + required: true ``` ## Related Documentation diff --git a/docs/specs/delegation-challenge-encoding.md b/docs/specs/delegation-challenge-encoding.md index 2217fc8..fc5c844 100644 --- a/docs/specs/delegation-challenge-encoding.md +++ b/docs/specs/delegation-challenge-encoding.md @@ -151,7 +151,7 @@ This structure aligns with [OID4VP §5.1 `transaction_data`](https://openid.net/ } ``` -### 3.5 Computing the Hash +### 3.6 Computing the Hash ```python import hashlib @@ -269,14 +269,16 @@ This specification is designed for seamless integration with [OpenID for Verifia ```json { - "type": "harbour_delegated_signing", - "credential_ids": ["user_identity_credential"], + "type": "harbour_delegate:data.purchase", + "credential_ids": ["simpulse_id"], "transaction_data_hashes_alg": ["sha-256"], - "action": "data.purchase", - "transaction": { + "nonce": "da9b1009", + "iat": 1771934400, + "txn": { "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", - "currency": "ENVITED" + "currency": "ENVITED", + "marketplace": "did:web:dataspace.envited.io" } } ``` @@ -353,180 +355,56 @@ A verifier (signing service) MUST: ### 8.1 Python -```python -import hashlib -import json -import secrets -from datetime import datetime, timezone -from dataclasses import dataclass, field, asdict -from typing import Any - - -@dataclass -class TransactionData: - """Full transaction data object.""" - action: str - timestamp: str - nonce: str - transaction: dict[str, Any] - type: str = "HarbourDelegatedTransaction" - version: str = "1.0" - metadata: dict[str, Any] = field(default_factory=dict) - - def to_dict(self) -> dict: - return asdict(self) - - def compute_hash(self) -> str: - """Compute SHA-256 hash of canonical JSON representation.""" - canonical = json.dumps(self.to_dict(), sort_keys=True, separators=(',', ':')) - return hashlib.sha256(canonical.encode('utf-8')).hexdigest() - - -def create_delegation_challenge( - transaction_data: TransactionData, -) -> str: - """Create a Harbour delegation challenge string. - - Format: HARBOUR_DELEGATE - """ - tx_hash = transaction_data.compute_hash() - return f"{transaction_data.nonce} HARBOUR_DELEGATE {tx_hash}" - +The implementation is in `src/python/harbour/delegation.py`: -def parse_delegation_challenge(challenge: str) -> tuple[str, str, str]: - """Parse a Harbour delegation challenge string. - - Returns: - Tuple of (nonce, action_type, hash) - """ - parts = challenge.split(' ') - if len(parts) != 3: - raise ValueError(f"Invalid challenge format: expected 3 parts, got {len(parts)}") - - nonce, action_type, tx_hash = parts - - if action_type != "HARBOUR_DELEGATE": - raise ValueError(f"Invalid action type: {action_type}") - - if len(tx_hash) != 64: - raise ValueError(f"Invalid hash length: expected 64, got {len(tx_hash)}") - - return nonce, action_type, tx_hash - - -def verify_challenge( - challenge: str, - transaction_data: TransactionData, -) -> bool: - """Verify that a challenge matches transaction data. - - Returns: - True if the hash in the challenge matches the transaction data - """ - nonce, _, challenge_hash = parse_delegation_challenge(challenge) - - if nonce != transaction_data.nonce: - return False - - computed_hash = transaction_data.compute_hash() - return challenge_hash == computed_hash - - -# Example usage -if __name__ == "__main__": - tx = TransactionData( - action="data.purchase", - timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), - nonce=secrets.token_hex(4), # 8 hex chars - transaction={ - "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", - "price": "100", - "currency": "ENVITED", - }, - metadata={"description": "Purchase sensor data package"}, - ) - - challenge = create_delegation_challenge(tx) - print(f"Challenge: {challenge}") - print(f"Valid: {verify_challenge(challenge, tx)}") +```python +from harbour.delegation import TransactionData, create_delegation_challenge, verify_challenge + +# Create OID4VP-aligned transaction data +tx = TransactionData.create( + action="data.purchase", + txn={ + "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "price": "100", + "currency": "ENVITED", + "marketplace": "did:web:dataspace.envited.io", + }, + credential_ids=["simpulse_id"], +) + +# Create challenge: " HARBOUR_DELEGATE " +challenge = create_delegation_challenge(tx) +print(f"Challenge: {challenge}") +print(f"Valid: {verify_challenge(challenge, tx)}") ``` ### 8.2 TypeScript -```typescript -import { createHash, randomBytes } from 'crypto'; - -interface TransactionData { - type: 'HarbourDelegatedTransaction'; - version: '1.0'; - action: string; - timestamp: string; - nonce: string; - transaction: Record; - metadata?: Record; -} - -function computeTransactionHash(data: TransactionData): string { - const canonical = JSON.stringify(data, Object.keys(data).sort()); - return createHash('sha256').update(canonical).digest('hex'); -} - -function createDelegationChallenge(data: TransactionData): string { - const hash = computeTransactionHash(data); - return `${data.nonce} HARBOUR_DELEGATE ${hash}`; -} - -function parseDelegationChallenge(challenge: string): { - nonce: string; - actionType: string; - hash: string; -} { - const parts = challenge.split(' '); - if (parts.length !== 3) { - throw new Error(`Invalid challenge format: expected 3 parts, got ${parts.length}`); - } - - const [nonce, actionType, hash] = parts; - - if (actionType !== 'HARBOUR_DELEGATE') { - throw new Error(`Invalid action type: ${actionType}`); - } - - if (hash.length !== 64) { - throw new Error(`Invalid hash length: expected 64, got ${hash.length}`); - } - - return { nonce, actionType, hash }; -} - -function verifyChallenge(challenge: string, data: TransactionData): boolean { - const { nonce, hash: challengeHash } = parseDelegationChallenge(challenge); - - if (nonce !== data.nonce) { - return false; - } - - const computedHash = computeTransactionHash(data); - return challengeHash === computedHash; -} +The implementation is in `src/typescript/harbour/delegation.ts`: -// Example usage -const tx: TransactionData = { - type: 'HarbourDelegatedTransaction', - version: '1.0', +```typescript +import { + createTransactionData, + createDelegationChallenge, + verifyChallenge, +} from '@reachhaven/harbour-credentials'; + +// Create OID4VP-aligned transaction data +const tx = createTransactionData({ action: 'data.purchase', - timestamp: new Date().toISOString(), - nonce: randomBytes(4).toString('hex'), - transaction: { + txn: { assetId: 'urn:uuid:550e8400-e29b-41d4-a716-446655440000', price: '100', currency: 'ENVITED', + marketplace: 'did:web:dataspace.envited.io', }, - metadata: { description: 'Purchase sensor data package' }, -}; + credentialIds: ['simpulse_id'], +}); -console.log('Challenge:', createDelegationChallenge(tx)); -console.log('Valid:', verifyChallenge(createDelegationChallenge(tx), tx)); +// Create challenge: " HARBOUR_DELEGATE " +const challenge = await createDelegationChallenge(tx); +console.log('Challenge:', challenge); +console.log('Valid:', await verifyChallenge(challenge, tx)); ``` --- @@ -581,113 +459,25 @@ Wallet/application implementations SHOULD: ### 9.4 Python Display Renderer ```python -ACTION_LABELS = { - "blockchain.transfer": "Transfer tokens", - "blockchain.approve": "Approve token spending", - "blockchain.execute": "Execute smart contract", - "contract.sign": "Sign contract", - "contract.accept": "Accept agreement", - "data.purchase": "Purchase data asset", - "data.share": "Share data", - "credential.issue": "Issue credential", - "credential.present": "Present credential", -} +from harbour.delegation import TransactionData, render_transaction_display -def render_transaction_display( - transaction_data: TransactionData, - service_name: str = "Harbour Signing Service" -) -> str: - """Render transaction data for human-readable display. - - Args: - transaction_data: The full transaction data object - service_name: Human-friendly name for the signing service - - Returns: - Multi-line string suitable for display to user - """ - action = transaction_data.action - action_label = ACTION_LABELS.get(action, action.replace(".", " ").title()) - - lines = [ - f"{service_name} requests your authorization", - "─" * 50, - "", - f" Action: {action_label}", - ] - - # Add transaction-specific fields - for key, value in transaction_data.transaction.items(): - display_key = key.replace("_", " ").title() - display_value = str(value) - if len(display_value) > 40: - display_value = display_value[:37] + "..." - lines.append(f" {display_key}: {display_value}") - - lines.extend([ - "", - "─" * 50, - f" Nonce: {transaction_data.nonce}", - f" Time: {transaction_data.timestamp}", - ]) - - if transaction_data.metadata.get("expiresAt"): - lines.append(f" Expires: {transaction_data.metadata['expiresAt']}") - - return "\n".join(lines) +tx = TransactionData.create( + action="data.purchase", + txn={"assetId": "urn:uuid:550e8400...", "price": "100", "currency": "ENVITED"}, +) +print(render_transaction_display(tx)) ``` ### 9.5 TypeScript Display Renderer ```typescript -const ACTION_LABELS: Record = { - 'blockchain.transfer': 'Transfer tokens', - 'blockchain.approve': 'Approve token spending', - 'blockchain.execute': 'Execute smart contract', - 'contract.sign': 'Sign contract', - 'contract.accept': 'Accept agreement', - 'data.purchase': 'Purchase data asset', - 'data.share': 'Share data', - 'credential.issue': 'Issue credential', - 'credential.present': 'Present credential', -}; - -function renderTransactionDisplay( - data: TransactionData, - serviceName = 'Harbour Signing Service' -): string { - const actionLabel = ACTION_LABELS[data.action] ?? - data.action.replace('.', ' ').replace(/\b\w/g, c => c.toUpperCase()); - - const lines: string[] = [ - `${serviceName} requests your authorization`, - '─'.repeat(50), - '', - ` Action: ${actionLabel}`, - ]; - - for (const [key, value] of Object.entries(data.transaction)) { - const displayKey = key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); - let displayValue = String(value); - if (displayValue.length > 40) { - displayValue = displayValue.slice(0, 37) + '...'; - } - lines.push(` ${displayKey}: ${displayValue}`); - } - - lines.push( - '', - '─'.repeat(50), - ` Nonce: ${data.nonce}`, - ` Time: ${data.timestamp}`, - ); - - if (data.metadata?.expiresAt) { - lines.push(` Expires: ${data.metadata.expiresAt}`); - } - - return lines.join('\n'); -} +import { createTransactionData, renderTransactionDisplay } from '@reachhaven/harbour-credentials'; + +const tx = createTransactionData({ + action: 'data.purchase', + txn: { assetId: 'urn:uuid:550e8400...', price: '100', currency: 'ENVITED' }, +}); +console.log(renderTransactionDisplay(tx)); ``` --- @@ -696,15 +486,17 @@ function renderTransactionDisplay( ### 10.1 Data Purchase Transaction +These examples use the shared test vectors from `tests/fixtures/canonicalization-vectors.json`. + **Transaction Data:** ```json { - "type": "HarbourDelegatedTransaction", - "version": "1.0", - "action": "data.purchase", - "timestamp": "2026-02-24T12:00:00Z", + "type": "harbour_delegate:data.purchase", + "credential_ids": ["simpulse_id"], + "transaction_data_hashes_alg": ["sha-256"], "nonce": "da9b1009", - "transaction": { + "iat": 1771934400, + "txn": { "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", @@ -715,7 +507,7 @@ function renderTransactionDisplay( **Challenge:** ``` -da9b1009 HARBOUR_DELEGATE d0450062b3c4c9168ac8266f0806d62f5d95ed96894d5a9a0aaddf4298317eaa +da9b1009 HARBOUR_DELEGATE 86a3e927a80d9e858a71b37f574aa65cab184c5fc65e8c7824771f84ad6ed97e ``` ### 10.2 Blockchain Transfer Transaction @@ -723,24 +515,23 @@ da9b1009 HARBOUR_DELEGATE d0450062b3c4c9168ac8266f0806d62f5d95ed96894d5a9a0aaddf **Transaction Data:** ```json { - "type": "HarbourDelegatedTransaction", - "version": "1.0", - "action": "blockchain.transfer", - "timestamp": "2026-02-24T12:30:00Z", - "nonce": "ab12cd34", - "transaction": { + "type": "harbour_delegate:blockchain.transfer", + "credential_ids": ["default"], + "transaction_data_hashes_alg": ["sha-256"], + "nonce": "ef567890", + "iat": 1771934400, + "txn": { "chain": "eip155:42793", - "contract": "0x1234567890abcdef1234567890abcdef12345678", - "recipient": "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd", "amount": "1000000000000000000", - "token": "ENVITED" + "recipient": "0xabcdef1234567890", + "contract": "0x1234567890abcdef" } } ``` **Challenge:** ``` -ab12cd34 HARBOUR_DELEGATE 7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b +ef567890 HARBOUR_DELEGATE 0736db89c15be412294f96717a3e435f89d095e7e953b1808c422252b845d4c1 ``` ### 10.3 Contract Signature Transaction @@ -748,22 +539,25 @@ ab12cd34 HARBOUR_DELEGATE 7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c **Transaction Data:** ```json { - "type": "HarbourDelegatedTransaction", - "version": "1.0", - "action": "contract.sign", - "timestamp": "2026-02-24T13:00:00Z", - "nonce": "ef567890", - "transaction": { - "documentHash": "sha256:abc123def456...", - "documentUri": "https://contracts.example.com/abc123", + "type": "harbour_delegate:contract.sign", + "credential_ids": ["org_credential"], + "transaction_data_hashes_alg": ["sha-256"], + "nonce": "ab12cd34", + "iat": 1771934400, + "exp": 1771935300, + "description": "Sign partnership agreement", + "txn": { + "documentHash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "parties": ["did:web:alice.example", "did:web:bob.example"] - }, - "metadata": { - "expiresAt": "2026-02-24T13:15:00Z" } } ``` +**Challenge:** +``` +ab12cd34 HARBOUR_DELEGATE daccac20a99de56e2a5d108fcfa53d0d03faa7d4cd29552ae1dbdc486120d3ec +``` + --- ## 11. Relationship to W3C Standards @@ -792,7 +586,8 @@ This specification aligns with [OID4VP Transaction Data (§8.4)](https://openid. | OID4VP Concept | Harbour Delegation Equivalent | |----------------|-------------------------------| | `transaction_data` request param | Transaction Data Object (§3) | -| `transaction_data.type` | `"harbour_delegated_signing"` | +| `transaction_data.type` | `"harbour_delegate:"` | +| `transaction_data.txn` | Action-specific transaction details | | `transaction_data_hashes` in KB-JWT | Same hash as in `proof.challenge` | | `transaction_data_hashes_alg` | `"sha-256"` | @@ -802,17 +597,19 @@ OID4VP authorization request: ```json { "response_type": "vp_token", - "client_id": "did:web:harbour.signing-service.example.com", - "nonce": "n-0S6_WzA2Mj", + "client_id": "did:web:signing-service.envited.io", + "nonce": "da9b1009", "transaction_data": [{ - "type": "harbour_delegated_signing", + "type": "harbour_delegate:data.purchase", "credential_ids": ["simpulse_id"], "transaction_data_hashes_alg": ["sha-256"], - "transaction": { - "action": "data.purchase", + "nonce": "da9b1009", + "iat": 1771934400, + "txn": { "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", - "currency": "ENVITED" + "currency": "ENVITED", + "marketplace": "did:web:dataspace.envited.io" } }] } @@ -830,12 +627,12 @@ This specification draws design inspiration from [Sign-In with Ethereum (SIWE)]( |--------------|-------------------------------| | `domain` | `proof.domain` (signing service DID) | | `address` | Holder DID (in VP) | -| `statement` | `metadata.description` (human-readable) | -| `uri` | Transaction reference (in transaction object) | +| `statement` | `description` field (human-readable) | +| `uri` | Transaction reference (in `txn` object) | | `nonce` | `nonce` field | -| `issued-at` | `timestamp` field | -| `expiration-time` | `metadata.expiresAt` | -| `chain-id` | Implicit in transaction fields (e.g., `chain: "eip155:42793"`) | +| `issued-at` | `iat` field (Unix timestamp) | +| `expiration-time` | `exp` field (Unix timestamp) | +| `chain-id` | Implicit in `txn` fields (e.g., `chain: "eip155:42793"`) | **Key differences**: diff --git a/examples/consent-vp.json b/examples/consent-vp.json new file mode 100644 index 0000000..8b6f3bd --- /dev/null +++ b/examples/consent-vp.json @@ -0,0 +1,41 @@ +{ + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["VerifiablePresentation"], + "holder": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/credentials/v1/" + ], + "type": ["VerifiableCredential", "harbour:NaturalPersonCredential"], + "issuer": "did:web:issuer.example.com", + "validFrom": "2024-01-15T00:00:00Z", + "credentialSubject": { + "id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "type": "harbour:NaturalPerson", + "name": "Alice Smith", + "memberOf": "did:web:participant.example.com" + } + } + ], + "evidence": [ + { + "type": "harbour:DelegatedSignatureEvidence", + "transactionData": { + "type": "harbour_delegate:data.purchase", + "credential_ids": ["simpulse_id"], + "transaction_data_hashes_alg": ["sha-256"], + "nonce": "da9b1009", + "iat": 1771934400, + "txn": { + "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "price": "100", + "currency": "ENVITED", + "marketplace": "did:web:dataspace.envited.io" + } + }, + "delegatedTo": "did:web:signing-service.envited.io" + } + ] +} diff --git a/examples/delegated-signing-receipt.json b/examples/delegated-signing-receipt.json new file mode 100644 index 0000000..cd82316 --- /dev/null +++ b/examples/delegated-signing-receipt.json @@ -0,0 +1,65 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/credentials/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:DelegatedSigningReceipt" + ], + "id": "urn:uuid:f7e8d9c0-b1a2-3456-7890-abcdef012345", + "issuer": "did:web:signing-service.envited.io", + "validFrom": "2025-06-25T10:00:00Z", + "credentialSubject": { + "id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "type": "harbour:TransactionReceipt", + "transactionHash": "86a3e927a80d9e858a71b37f574aa65cab184c5fc65e8c7824771f84ad6ed97e", + "blockchainTxId": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + }, + "credentialStatus": [ + { + "id": "did:web:signing-service.envited.io:services:revocation-registry#f7e8d9c0b1a23456", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ], + "evidence": [ + { + "type": "harbour:DelegatedSignatureEvidence", + "verifiablePresentation": { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["VerifiablePresentation"], + "holder": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/credentials/v1/" + ], + "type": ["VerifiableCredential", "harbour:NaturalPersonCredential"], + "issuer": "did:web:issuer.example.com", + "credentialSubject": { + "id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "type": "harbour:NaturalPerson", + "memberOf": "did:web:participant.example.com" + } + } + ] + }, + "delegatedTo": "did:web:signing-service.envited.io", + "transactionData": { + "type": "harbour_delegate:data.purchase", + "credential_ids": ["simpulse_id"], + "transaction_data_hashes_alg": ["sha-256"], + "nonce": "da9b1009", + "iat": 1771934400, + "txn": { + "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "price": "100", + "currency": "ENVITED", + "marketplace": "did:web:dataspace.envited.io" + } + } + } + ] +} diff --git a/examples/legal-person-credential.json b/examples/legal-person-credential.json index f4063dc..9827a5e 100644 --- a/examples/legal-person-credential.json +++ b/examples/legal-person-credential.json @@ -43,7 +43,7 @@ ], "evidence": [ { - "type": "harbour:IssuanceEvidence", + "type": "harbour:CredentialEvidence", "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], diff --git a/examples/natural-person-credential.json b/examples/natural-person-credential.json index 178c5bc..92310ea 100644 --- a/examples/natural-person-credential.json +++ b/examples/natural-person-credential.json @@ -34,7 +34,7 @@ ], "evidence": [ { - "type": "harbour:EmailVerification", + "type": "harbour:CredentialEvidence", "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], diff --git a/linkml/harbour.yaml b/linkml/harbour.yaml index 7a85d52..5a4f7a1 100644 --- a/linkml/harbour.yaml +++ b/linkml/harbour.yaml @@ -109,12 +109,14 @@ slots: required: false # --- Delegated Signature Evidence Slots --- - transactionIntent: + transactionData: description: > - The action or transaction the user is consenting to. Binds consent - to a specific action for auditability. - slot_uri: harbour:transactionIntent - range: TransactionIntent + OID4VP-aligned transaction data object (§8.4). Contains action type, + credential IDs, timestamps, and action-specific details in the txn field. + On the receipt SD-JWT-VC this is a selectively disclosable claim enabling + three-layer privacy (public / authorized / full audit). + slot_uri: harbour:transactionData + range: Any required: false delegatedTo: @@ -123,34 +125,6 @@ slots: range: uri required: false - actionType: - description: > - Type of action being consented to (e.g., "purchase", "transfer", - "sign-contract", "grant-access"). - slot_uri: harbour:actionType - range: string - required: true - - actionReference: - description: > - URI or hash referencing the full transaction details. May be a URN, - IPFS hash, or URL to transaction specification. - slot_uri: harbour:actionReference - range: uri - required: false - - consentTimestamp: - description: Timestamp when the user consented to the action. - slot_uri: harbour:consentTimestamp - range: datetime - required: true - - consentNonce: - description: Unique nonce for replay protection. - slot_uri: harbour:consentNonce - range: string - required: true - # --- Credential Envelope Slots --- issuer: slot_uri: cred:issuer @@ -286,20 +260,13 @@ classes: slots: - type - EmailVerification: + CredentialEvidence: is_a: Evidence - description: Evidence that an email address was verified (e.g. via Altme EmailPass). - class_uri: harbour:EmailVerification - slots: - - verifiablePresentation - slot_usage: - verifiablePresentation: - required: true - - IssuanceEvidence: - is_a: Evidence - description: Evidence referencing a previously issued credential. - class_uri: harbour:IssuanceEvidence + description: > + Evidence that the issuer verified claims using a prior credential + or verifiable presentation. The embedded VP contains the credentials + the issuer relied upon (e.g., email verification, notary attestation). + class_uri: harbour:CredentialEvidence slots: - verifiablePresentation slot_usage: @@ -309,41 +276,21 @@ classes: DelegatedSignatureEvidence: is_a: Evidence description: > - Evidence of user consent for a delegated signature operation. The signing - service executes a transaction on behalf of the user, with the VP serving - as cryptographic proof of consent. For public auditability, the VP should - use SD-JWT format with PII claims redacted. + Evidence on a receipt credential (SD-JWT-VC) that a signing service + executed a transaction with the user's explicit consent. The consent VP + uses SD-JWT with PII redacted. Transaction data is a disclosable claim + enabling three-layer privacy (public / authorized / full audit). class_uri: harbour:DelegatedSignatureEvidence slots: - verifiablePresentation - - transactionIntent - delegatedTo + - transactionData slot_usage: verifiablePresentation: required: true - description: > - The consent VP signed by the user. Should be SD-JWT VP format for - privacy-preserving public audit. - transactionIntent: - required: true - - TransactionIntent: - description: > - Describes the action or transaction the user is consenting to. Included - in the VP evidence to bind consent to a specific action for auditability. - class_uri: harbour:TransactionIntent - slots: - - actionType - - actionReference - - description - - consentTimestamp - - consentNonce - slot_usage: - actionType: - required: true - consentTimestamp: + delegatedTo: required: true - consentNonce: + transactionData: required: true CRSetEntry: diff --git a/src/python/credentials/example_signer.py b/src/python/credentials/example_signer.py index 79ecdc8..15f42fb 100644 --- a/src/python/credentials/example_signer.py +++ b/src/python/credentials/example_signer.py @@ -244,7 +244,13 @@ def main(): for path_str in args.examples: path = Path(path_str) if path.is_dir(): - example_files.extend(sorted(path.glob("*.json"))) + # Only process credential/receipt files, skip VPs and other artifacts + example_files.extend( + p + for p in sorted(path.glob("*.json")) + if p.parent.name != "signed" + and any(t in p.stem for t in ("credential", "receipt", "offering")) + ) elif path.is_file(): example_files.append(path) else: diff --git a/src/python/harbour/__init__.py b/src/python/harbour/__init__.py index 9b8424e..98c43da 100644 --- a/src/python/harbour/__init__.py +++ b/src/python/harbour/__init__.py @@ -6,11 +6,15 @@ - VC/VP verification - SD-JWT-VC selective disclosure credentials - Key Binding JWT for holder binding +- Delegated signing evidence (OID4VP-aligned) +- SD-JWT VP issue/verify with evidence - X.509 certificate support Usage: from harbour import keys, signer, verifier from harbour.sd_jwt import issue_sd_jwt_vc, verify_sd_jwt_vc + from harbour.delegation import TransactionData, create_delegation_challenge + from harbour.sd_jwt_vp import issue_sd_jwt_vp, verify_sd_jwt_vp """ @@ -38,6 +42,20 @@ def __getattr__(name): from harbour import verifier return getattr(verifier, name) + elif name in ( + "TransactionData", + "create_delegation_challenge", + "parse_delegation_challenge", + "verify_challenge", + "ChallengeError", + ): + from harbour import delegation + + return getattr(delegation, name) + elif name in ("issue_sd_jwt_vp", "verify_sd_jwt_vp"): + from harbour import sd_jwt_vp + + return getattr(sd_jwt_vp, name) raise AttributeError(f"module 'harbour' has no attribute {name!r}") @@ -58,4 +76,13 @@ def __getattr__(name): "verify_vc_jose", "verify_vp_jose", "VerificationError", + # Delegation + "TransactionData", + "create_delegation_challenge", + "parse_delegation_challenge", + "verify_challenge", + "ChallengeError", + # SD-JWT VP + "issue_sd_jwt_vp", + "verify_sd_jwt_vp", ] diff --git a/src/python/harbour/delegation.py b/src/python/harbour/delegation.py index dfdfd46..9ae9f86 100644 --- a/src/python/harbour/delegation.py +++ b/src/python/harbour/delegation.py @@ -6,7 +6,7 @@ The challenge format is: HARBOUR_DELEGATE Where the hash is computed over a canonical JSON representation of the -transaction data object. +OID4VP-aligned transaction data object (§8.4). See docs/specs/delegation-challenge-encoding.md for the full specification. @@ -25,13 +25,17 @@ import json import secrets import sys +import time from dataclasses import asdict, dataclass, field -from datetime import datetime, timezone +from pathlib import Path from typing import Any # Action type identifier ACTION_TYPE = "HARBOUR_DELEGATE" +# Type prefix for transaction data +TYPE_PREFIX = "harbour_delegate" + # Human-friendly labels for action types ACTION_LABELS = { "blockchain.transfer": "Transfer tokens", @@ -58,32 +62,45 @@ class ChallengeError(ValueError): @dataclass class TransactionData: - """Full transaction data object for delegated signing. + """OID4VP-aligned transaction data object for delegated signing. - This object contains all details about the transaction being authorized. + This object follows the OID4VP §8.4 transaction_data structure. The challenge contains only a hash of this object for compactness. Attributes: - action: The action being delegated (e.g., "data.purchase") - timestamp: ISO8601 timestamp when the transaction was created + type: Transaction data type identifier (harbour_delegate:) + credential_ids: References to DCQL Credential Query id fields nonce: Unique identifier for replay protection - transaction: Action-specific transaction details - type: Fixed type identifier (HarbourDelegatedTransaction) - version: Schema version (1.0) - metadata: Optional additional context (description, expiration, etc.) + iat: Issued-at Unix timestamp (seconds since epoch) + txn: Action-specific transaction details + exp: Optional expiration Unix timestamp + description: Optional human-readable description + transaction_data_hashes_alg: Hash algorithms supported (default: ["sha-256"]) """ - action: str - timestamp: str + type: str + credential_ids: list[str] nonce: str - transaction: dict[str, Any] - type: str = "HarbourDelegatedTransaction" - version: str = "1.0" - metadata: dict[str, Any] = field(default_factory=dict) + iat: int + txn: dict[str, Any] + exp: int | None = None + description: str | None = None + transaction_data_hashes_alg: list[str] = field(default_factory=lambda: ["sha-256"]) + + @property + def action(self) -> str: + """Extract the action from the type field. + + E.g., "harbour_delegate:data.purchase" -> "data.purchase" + """ + if ":" in self.type: + return self.type.split(":", 1)[1] + return self.type def to_dict(self) -> dict[str, Any]: - """Convert to dictionary representation.""" - return asdict(self) + """Convert to dictionary representation, omitting None values.""" + d = asdict(self) + return {k: v for k, v in d.items() if v is not None} def to_json(self, canonical: bool = True) -> str: """Convert to JSON string. @@ -108,13 +125,16 @@ def compute_hash(self) -> str: def from_dict(cls, data: dict[str, Any]) -> TransactionData: """Create from dictionary.""" return cls( - action=data["action"], - timestamp=data["timestamp"], + type=data["type"], + credential_ids=data["credential_ids"], nonce=data["nonce"], - transaction=data["transaction"], - type=data.get("type", "HarbourDelegatedTransaction"), - version=data.get("version", "1.0"), - metadata=data.get("metadata", {}), + iat=data["iat"], + txn=data["txn"], + exp=data.get("exp"), + description=data.get("description"), + transaction_data_hashes_alg=data.get( + "transaction_data_hashes_alg", ["sha-256"] + ), ) @classmethod @@ -126,33 +146,42 @@ def from_json(cls, json_str: str) -> TransactionData: def create( cls, action: str, - transaction: dict[str, Any], + txn: dict[str, Any], *, + credential_ids: list[str] | None = None, nonce: str | None = None, - timestamp: datetime | None = None, - metadata: dict[str, Any] | None = None, + iat: int | None = None, + exp: int | None = None, + description: str | None = None, ) -> TransactionData: """Create a new transaction data object. Args: - action: The action being delegated - transaction: Action-specific transaction details + action: The action being delegated (e.g., "data.purchase") + txn: Action-specific transaction details + credential_ids: DCQL credential query IDs (default: ["default"]) nonce: Unique identifier (auto-generated if not provided) - timestamp: Transaction timestamp (defaults to now) - metadata: Optional additional context + iat: Issued-at Unix timestamp (defaults to now) + exp: Optional expiration Unix timestamp + description: Optional human-readable description """ if nonce is None: nonce = secrets.token_hex(4) # 8 hex characters - if timestamp is None: - timestamp = datetime.now(timezone.utc) + if iat is None: + iat = int(time.time()) + + if credential_ids is None: + credential_ids = ["default"] return cls( - action=action, - timestamp=timestamp.strftime("%Y-%m-%dT%H:%M:%SZ"), + type=f"{TYPE_PREFIX}:{action}", + credential_ids=credential_ids, nonce=nonce, - transaction=transaction, - metadata=metadata or {}, + iat=iat, + txn=txn, + exp=exp, + description=description, ) @@ -170,7 +199,7 @@ def create_delegation_challenge(transaction_data: TransactionData) -> str: Example: >>> tx = TransactionData.create( ... action="data.purchase", - ... transaction={"assetId": "urn:uuid:...", "price": "100"}, + ... txn={"assetId": "urn:uuid:...", "price": "100"}, ... ) >>> challenge = create_delegation_challenge(tx) >>> print(challenge) @@ -270,13 +299,13 @@ def render_transaction_display( lines = [ f"{service_name} requests your authorization", - "─" * 50, + "\u2500" * 50, "", f" Action: {action_label}", ] # Add transaction-specific fields - for key, value in transaction_data.transaction.items(): + for key, value in transaction_data.txn.items(): display_key = key.replace("_", " ").replace("Id", " ID").title() display_value = str(value) if len(display_value) > 40: @@ -286,17 +315,17 @@ def render_transaction_display( lines.extend( [ "", - "─" * 50, + "\u2500" * 50, f" Nonce: {transaction_data.nonce}", - f" Time: {transaction_data.timestamp}", + f" Issued at: {transaction_data.iat}", ] ) - if transaction_data.metadata.get("expiresAt"): - lines.append(f" Expires: {transaction_data.metadata['expiresAt']}") + if transaction_data.exp is not None: + lines.append(f" Expires: {transaction_data.exp}") - if transaction_data.metadata.get("description"): - lines.append(f" Details: {transaction_data.metadata['description']}") + if transaction_data.description: + lines.append(f" Details: {transaction_data.description}") return "\n".join(lines) @@ -315,10 +344,10 @@ def validate_transaction_data( Raises: ChallengeError: If validation fails """ - # Validate type - if transaction_data.type != "HarbourDelegatedTransaction": + # Validate type prefix + if not transaction_data.type.startswith(f"{TYPE_PREFIX}:"): raise ChallengeError( - f"Invalid type: expected 'HarbourDelegatedTransaction', got '{transaction_data.type}'" + f"Invalid type: expected '{TYPE_PREFIX}:*', got '{transaction_data.type}'" ) # Validate nonce length (minimum 8 hex characters = 32 bits) @@ -327,40 +356,22 @@ def validate_transaction_data( f"Nonce too short: {len(transaction_data.nonce)} chars (minimum 8)" ) - # Parse and validate timestamp - try: - ts = datetime.strptime( - transaction_data.timestamp, "%Y-%m-%dT%H:%M:%SZ" - ).replace(tzinfo=timezone.utc) - except ValueError as e: - raise ChallengeError(f"Invalid timestamp format: {e}") from e - - now = datetime.now(timezone.utc) - age = (now - ts).total_seconds() + # Validate iat (Unix timestamp) + now = int(time.time()) + age = now - transaction_data.iat if age > max_age_seconds: - raise ChallengeError( - f"Transaction too old: {age:.0f}s (max {max_age_seconds}s)" - ) + raise ChallengeError(f"Transaction too old: {age}s (max {max_age_seconds}s)") if age < -60: # Allow 1 minute clock skew raise ChallengeError( - f"Transaction timestamp is in the future: {transaction_data.timestamp}" + f"Transaction timestamp is in the future: iat={transaction_data.iat}" ) # Check expiration if present - if transaction_data.metadata.get("expiresAt"): - try: - exp = datetime.strptime( - transaction_data.metadata["expiresAt"], "%Y-%m-%dT%H:%M:%SZ" - ).replace(tzinfo=timezone.utc) - except ValueError as e: - raise ChallengeError(f"Invalid expiration format: {e}") from e - - if now > exp: - raise ChallengeError( - f"Transaction expired at {transaction_data.metadata['expiresAt']}" - ) + if transaction_data.exp is not None: + if now > transaction_data.exp: + raise ChallengeError(f"Transaction expired at {transaction_data.exp}") def main(): @@ -404,6 +415,9 @@ def main(): create_parser.add_argument("--contract", help="Contract address") create_parser.add_argument("--recipient", help="Recipient address") create_parser.add_argument("--desc", help="Description") + create_parser.add_argument( + "--credential-ids", nargs="*", help="DCQL credential query IDs" + ) create_parser.add_argument("--exp-minutes", type=int, help="Expiration in minutes") create_parser.add_argument( "--output-json", action="store_true", help="Output full JSON" @@ -439,33 +453,30 @@ def main(): try: if args.command == "create": # Build transaction dict from args - transaction = {} + txn = {} if args.asset_id: - transaction["assetId"] = args.asset_id + txn["assetId"] = args.asset_id if args.price: - transaction["price"] = args.price + txn["price"] = args.price if args.currency: - transaction["currency"] = args.currency + txn["currency"] = args.currency if args.chain: - transaction["chain"] = args.chain + txn["chain"] = args.chain if args.contract: - transaction["contract"] = args.contract + txn["contract"] = args.contract if args.recipient: - transaction["recipient"] = args.recipient + txn["recipient"] = args.recipient - metadata = {} - if args.desc: - metadata["description"] = args.desc + exp = None if args.exp_minutes: - from datetime import timedelta - - exp = datetime.now(timezone.utc) + timedelta(minutes=args.exp_minutes) - metadata["expiresAt"] = exp.strftime("%Y-%m-%dT%H:%M:%SZ") + exp = int(time.time()) + args.exp_minutes * 60 tx = TransactionData.create( action=args.action, - transaction=transaction, - metadata=metadata if metadata else None, + txn=txn, + credential_ids=args.credential_ids, + description=args.desc, + exp=exp, ) if args.output_json: @@ -483,20 +494,24 @@ def main(): print(f"Hash: {tx_hash}") elif args.command == "display": - with open(args.json_file, "r") as f: - tx = TransactionData.from_json(f.read()) + tx = TransactionData.from_json( + Path(args.json_file).read_text(encoding="utf-8") + ) print(render_transaction_display(tx, args.service)) elif args.command == "verify": - with open(args.json_file, "r") as f: - tx = TransactionData.from_json(f.read()) + tx = TransactionData.from_json( + Path(args.json_file).read_text(encoding="utf-8") + ) validate_transaction_data(tx, max_age_seconds=args.max_age) if verify_challenge(args.challenge, tx): - print("✓ Challenge is valid and matches transaction data") + print("\u2713 Challenge is valid and matches transaction data") else: - print("✗ Challenge does not match transaction data", file=sys.stderr) + print( + "\u2717 Challenge does not match transaction data", file=sys.stderr + ) sys.exit(1) except ChallengeError as e: diff --git a/src/python/harbour/sd_jwt_vp.py b/src/python/harbour/sd_jwt_vp.py index 1f5cb24..c6eb39a 100644 --- a/src/python/harbour/sd_jwt_vp.py +++ b/src/python/harbour/sd_jwt_vp.py @@ -66,7 +66,9 @@ def issue_sd_jwt_vp( disclosures: Which disclosures to include (by claim name). If None, includes all available disclosures. If empty list [], includes no disclosures (max privacy). - evidence: Evidence objects to include in the VP (e.g., transaction intent). + evidence: Evidence objects to include in the VP. Supported types: + - CredentialEvidence: prior credential/VP the issuer relied upon + - DelegatedSignatureEvidence: consent proof with transactionData nonce: Challenge nonce for replay protection. audience: Intended verifier (DID or URL). holder_did: Holder's DID for the VP. If not provided, will not be included. diff --git a/src/typescript/harbour/delegation.ts b/src/typescript/harbour/delegation.ts new file mode 100644 index 0000000..a49c3be --- /dev/null +++ b/src/typescript/harbour/delegation.ts @@ -0,0 +1,332 @@ +/** + * Harbour Delegated Signing Evidence. + * + * Implements the Harbour Delegated Signing Evidence Specification v2 + * for creating and verifying delegation challenges used in VP proof.challenge fields. + * + * The challenge format is: HARBOUR_DELEGATE + * + * Where the hash is computed over a canonical JSON representation of the + * OID4VP-aligned transaction data object (§8.4). + */ + +/** Action type identifier. */ +export const ACTION_TYPE = "HARBOUR_DELEGATE"; + +/** Type prefix for transaction data. */ +export const TYPE_PREFIX = "harbour_delegate"; + +/** Human-friendly labels for action types. */ +export const ACTION_LABELS: Record = { + "blockchain.transfer": "Transfer tokens", + "blockchain.approve": "Approve token spending", + "blockchain.execute": "Execute smart contract", + "blockchain.sign": "Sign blockchain message", + "contract.sign": "Sign contract", + "contract.accept": "Accept agreement", + "contract.reject": "Reject agreement", + "data.purchase": "Purchase data asset", + "data.share": "Share data", + "data.access": "Access data", + "credential.issue": "Issue credential", + "credential.revoke": "Revoke credential", + "credential.present": "Present credential", +}; + +/** Error parsing or validating a delegation challenge. */ +export class ChallengeError extends Error { + constructor(message: string) { + super(message); + this.name = "ChallengeError"; + } +} + +/** OID4VP-aligned transaction data object for delegated signing. */ +export interface TransactionData { + /** Transaction data type identifier (harbour_delegate:). */ + type: string; + /** References to DCQL Credential Query id fields. */ + credential_ids: string[]; + /** Unique identifier for replay protection. */ + nonce: string; + /** Issued-at Unix timestamp (seconds since epoch). */ + iat: number; + /** Action-specific transaction details. */ + txn: Record; + /** Optional expiration Unix timestamp. */ + exp?: number; + /** Optional human-readable description. */ + description?: string; + /** Hash algorithms supported (default: ["sha-256"]). */ + transaction_data_hashes_alg?: string[]; +} + +/** + * Extract the action from the type field. + * + * E.g., "harbour_delegate:data.purchase" -> "data.purchase" + */ +export function getAction(td: TransactionData): string { + const idx = td.type.indexOf(":"); + return idx >= 0 ? td.type.slice(idx + 1) : td.type; +} + +/** + * Convert TransactionData to a plain object, omitting undefined values. + */ +function toDict(td: TransactionData): Record { + const d: Record = { + type: td.type, + credential_ids: td.credential_ids, + nonce: td.nonce, + iat: td.iat, + txn: td.txn, + }; + if (td.exp !== undefined) d.exp = td.exp; + if (td.description !== undefined) d.description = td.description; + if (td.transaction_data_hashes_alg !== undefined) + d.transaction_data_hashes_alg = td.transaction_data_hashes_alg; + return d; +} + +/** + * Recursively sort all keys in a JSON-serializable value. + * + * Python's json.dumps(sort_keys=True) sorts ALL keys recursively. + * JavaScript JSON.stringify does NOT sort keys by default and does NOT + * accept a replacer that recursively sorts. This function creates a + * new object/array structure with sorted keys at every level. + */ +function sortKeysRecursive(value: unknown): unknown { + if (value === null || value === undefined) return value; + if (Array.isArray(value)) return value.map(sortKeysRecursive); + if (typeof value === "object") { + const sorted: Record = {}; + for (const key of Object.keys(value as Record).sort()) { + sorted[key] = sortKeysRecursive( + (value as Record)[key] + ); + } + return sorted; + } + return value; +} + +/** + * Convert TransactionData to canonical JSON string. + * + * Matches Python's json.dumps(sort_keys=True, separators=(',', ':')) + * which sorts ALL keys recursively with no whitespace. + */ +export function toCanonicalJson(td: TransactionData): string { + const dict = toDict(td); + return JSON.stringify(sortKeysRecursive(dict)); +} + +/** + * Compute SHA-256 hash of TransactionData canonical JSON. + * + * @returns Lowercase hex-encoded SHA-256 hash (64 characters). + */ +export async function computeTransactionHash( + td: TransactionData +): Promise { + const canonical = toCanonicalJson(td); + const encoder = new TextEncoder(); + const data = encoder.encode(canonical); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + const hashArray = new Uint8Array(hashBuffer); + return Array.from(hashArray) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * Create a Harbour delegation challenge string. + * + * Format: HARBOUR_DELEGATE + */ +export async function createDelegationChallenge( + td: TransactionData +): Promise { + const hash = await computeTransactionHash(td); + return `${td.nonce} ${ACTION_TYPE} ${hash}`; +} + +/** + * Parse a Harbour delegation challenge string. + * + * @returns Object with nonce, actionType, and hash. + * @throws ChallengeError if the format is invalid. + */ +export function parseDelegationChallenge(challenge: string): { + nonce: string; + actionType: string; + hash: string; +} { + const parts = challenge.split(" "); + if (parts.length !== 3) { + throw new ChallengeError( + `Invalid challenge format: expected 3 space-separated parts, got ${parts.length}` + ); + } + + const [nonce, actionType, hash] = parts; + + if (actionType !== ACTION_TYPE) { + throw new ChallengeError( + `Invalid action type: expected '${ACTION_TYPE}', got '${actionType}'` + ); + } + + if (hash.length !== 64) { + throw new ChallengeError( + `Invalid hash length: expected 64 hex characters, got ${hash.length}` + ); + } + + // Validate hex + if (!/^[0-9a-f]{64}$/.test(hash)) { + throw new ChallengeError("Invalid hash: not valid hexadecimal"); + } + + return { nonce, actionType, hash }; +} + +/** + * Verify that a challenge matches transaction data. + * + * @returns true if the hash in the challenge matches the transaction data. + */ +export async function verifyChallenge( + challenge: string, + td: TransactionData +): Promise { + const { nonce, hash: challengeHash } = parseDelegationChallenge(challenge); + + if (nonce !== td.nonce) return false; + + const computedHash = await computeTransactionHash(td); + return challengeHash === computedHash; +} + +/** + * Create a new TransactionData object. + */ +export function createTransactionData(options: { + action: string; + txn: Record; + credentialIds?: string[]; + nonce?: string; + iat?: number; + exp?: number; + description?: string; +}): TransactionData { + const nonce = + options.nonce ?? + Array.from(crypto.getRandomValues(new Uint8Array(4))) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + return { + type: `${TYPE_PREFIX}:${options.action}`, + credential_ids: options.credentialIds ?? ["default"], + nonce, + iat: options.iat ?? Math.floor(Date.now() / 1000), + txn: options.txn, + ...(options.exp !== undefined ? { exp: options.exp } : {}), + ...(options.description !== undefined + ? { description: options.description } + : {}), + transaction_data_hashes_alg: ["sha-256"], + }; +} + +/** + * Render transaction data for human-readable display. + */ +export function renderTransactionDisplay( + td: TransactionData, + serviceName = "Harbour Signing Service" +): string { + const action = getAction(td); + const actionLabel = + ACTION_LABELS[action] ?? + action + .replace(/\./g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); + + const lines: string[] = [ + `${serviceName} requests your authorization`, + "\u2500".repeat(50), + "", + ` Action: ${actionLabel}`, + ]; + + for (const [key, value] of Object.entries(td.txn)) { + const displayKey = key + .replace(/_/g, " ") + .replace(/Id/g, " ID") + .replace(/\b\w/g, (c) => c.toUpperCase()); + let displayValue = String(value); + if (displayValue.length > 40) { + displayValue = displayValue.slice(0, 37) + "..."; + } + lines.push(` ${displayKey}: ${displayValue}`); + } + + lines.push("", "\u2500".repeat(50), ` Nonce: ${td.nonce}`, ` Issued at: ${td.iat}`); + + if (td.exp !== undefined) { + lines.push(` Expires: ${td.exp}`); + } + + if (td.description) { + lines.push(` Details: ${td.description}`); + } + + return lines.join("\n"); +} + +/** + * Validate transaction data for security requirements. + * + * @throws ChallengeError if validation fails. + */ +export function validateTransactionData( + td: TransactionData, + options?: { maxAgeSeconds?: number } +): void { + const maxAge = options?.maxAgeSeconds ?? 300; + + if (!td.type.startsWith(`${TYPE_PREFIX}:`)) { + throw new ChallengeError( + `Invalid type: expected '${TYPE_PREFIX}:*', got '${td.type}'` + ); + } + + if (td.nonce.length < 8) { + throw new ChallengeError( + `Nonce too short: ${td.nonce.length} chars (minimum 8)` + ); + } + + const now = Math.floor(Date.now() / 1000); + const age = now - td.iat; + + if (age > maxAge) { + throw new ChallengeError( + `Transaction too old: ${age}s (max ${maxAge}s)` + ); + } + + if (age < -60) { + throw new ChallengeError( + `Transaction timestamp is in the future: iat=${td.iat}` + ); + } + + if (td.exp !== undefined && now > td.exp) { + throw new ChallengeError(`Transaction expired at ${td.exp}`); + } +} diff --git a/src/typescript/harbour/index.ts b/src/typescript/harbour/index.ts index c8b7a49..f385a54 100644 --- a/src/typescript/harbour/index.ts +++ b/src/typescript/harbour/index.ts @@ -40,3 +40,28 @@ export { type KbJwtPayload, type KbJwtVerifyOptions, } from "./kb-jwt.js"; + +export { + ACTION_TYPE, + TYPE_PREFIX, + ACTION_LABELS, + ChallengeError, + getAction, + toCanonicalJson, + computeTransactionHash, + createDelegationChallenge, + parseDelegationChallenge, + verifyChallenge, + createTransactionData, + renderTransactionDisplay, + validateTransactionData, + type TransactionData, +} from "./delegation.js"; + +export { + issueSdJwtVp, + verifySdJwtVp, + type IssueSdJwtVpOptions, + type VerifySdJwtVpOptions, + type SdJwtVpResult, +} from "./sd-jwt-vp.js"; diff --git a/src/typescript/harbour/sd-jwt-vp.ts b/src/typescript/harbour/sd-jwt-vp.ts new file mode 100644 index 0000000..0cc8393 --- /dev/null +++ b/src/typescript/harbour/sd-jwt-vp.ts @@ -0,0 +1,363 @@ +/** + * SD-JWT Verifiable Presentations for privacy-preserving consent. + * + * This module enables creating VPs where: + * - The inner credential is an SD-JWT-VC with selectively disclosed claims + * - The VP envelope includes evidence (e.g., DelegatedSignatureEvidence) + * - The VP is signed by the holder's key (KB-JWT style binding) + * + * The SD-JWT VP format is: + * ~~~~...~ + */ + +import { CompactSign, compactVerify } from "jose"; +import { VerificationError } from "./verifier.js"; + +const SD_JWT_SEPARATOR = "~"; + +export interface IssueSdJwtVpOptions { + /** Which disclosures to include by claim name. null = all, [] = none. */ + disclosures?: string[] | null; + /** Evidence objects to include in the VP. */ + evidence?: Record[]; + /** Challenge nonce for replay protection. */ + nonce?: string; + /** Intended verifier (DID or URL). */ + audience?: string; + /** Holder's DID. */ + holderDid?: string; +} + +export interface VerifySdJwtVpOptions { + expectedNonce?: string; + expectedAudience?: string; +} + +export interface SdJwtVpResult { + credential: Record; + holder?: string; + evidence?: Record[]; + nonce?: string; + audience?: string; +} + +/** + * Issue an SD-JWT VP with selective disclosure and evidence. + * + * @param sdJwtVc - The SD-JWT-VC string (~~...~). + * @param holderPrivateKey - Holder's private key for VP and KB-JWT signatures. + * @param options - VP options (disclosures, evidence, nonce, audience, holderDid). + * @returns SD-JWT VP string: ~~~ + */ +export async function issueSdJwtVp( + sdJwtVc: string, + holderPrivateKey: CryptoKey, + options: IssueSdJwtVpOptions = {} +): Promise { + const alg = resolveAlg(holderPrivateKey); + + // Parse the SD-JWT-VC + const parts = sdJwtVc.split(SD_JWT_SEPARATOR); + if (parts.length < 2) { + throw new Error("Invalid SD-JWT-VC format: missing separator"); + } + + const issuerJwt = parts[0]; + const allDisclosures = parts.slice(1).filter((p) => p.length > 0); + + // Build mapping: claim_name -> disclosure_string + const disclosureMap = new Map(); + for (const discB64 of allDisclosures) { + const discJson = JSON.parse( + new TextDecoder().decode(base64urlDecode(discB64)) + ); + if (Array.isArray(discJson) && discJson.length === 3) { + const [, claimName] = discJson; + disclosureMap.set(claimName as string, discB64); + } + } + + // Select which disclosures to include + let selectedDisclosures: string[]; + if (options.disclosures === null || options.disclosures === undefined) { + // Include all disclosures + selectedDisclosures = [...disclosureMap.values()]; + } else { + // Include only named disclosures + selectedDisclosures = []; + for (const name of options.disclosures) { + const disc = disclosureMap.get(name); + if (disc) selectedDisclosures.push(disc); + } + } + + // Build VP payload + const vpPayload: Record = { + vp: { + "@context": ["https://www.w3.org/ns/credentials/v2"], + type: ["VerifiablePresentation"], + ...(options.holderDid ? { holder: options.holderDid } : {}), + ...(options.evidence ? { evidence: options.evidence } : {}), + }, + iat: Math.floor(Date.now() / 1000), + }; + + if (options.holderDid) { + vpPayload.iss = options.holderDid; + } + if (options.nonce) { + vpPayload.nonce = options.nonce; + } + if (options.audience) { + vpPayload.aud = options.audience; + } + + // Hash of the issuer JWT for binding + const vcHashBuffer = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(issuerJwt) + ); + vpPayload._vc_hash = base64urlEncode(new Uint8Array(vcHashBuffer)); + + // Sign VP JWT + const vpPayloadBytes = new TextEncoder().encode(JSON.stringify(vpPayload)); + const vpSigner = new CompactSign(vpPayloadBytes); + vpSigner.setProtectedHeader({ alg, typ: "vp+sd-jwt" }); + const vpJwt = await vpSigner.sign(holderPrivateKey); + + // Create KB-JWT + const sdMaterial = + issuerJwt + + SD_JWT_SEPARATOR + + selectedDisclosures.join(SD_JWT_SEPARATOR); + const sdHashBuffer = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(sdMaterial) + ); + const kbPayload: Record = { + iat: Math.floor(Date.now() / 1000), + sd_hash: base64urlEncode(new Uint8Array(sdHashBuffer)), + }; + if (options.nonce) kbPayload.nonce = options.nonce; + if (options.audience) kbPayload.aud = options.audience; + + const kbPayloadBytes = new TextEncoder().encode(JSON.stringify(kbPayload)); + const kbSigner = new CompactSign(kbPayloadBytes); + kbSigner.setProtectedHeader({ alg, typ: "kb+jwt" }); + const kbJwt = await kbSigner.sign(holderPrivateKey); + + // Compose: vp-jwt~issuer-jwt~disc1~disc2~...~kb-jwt + return [vpJwt, issuerJwt, ...selectedDisclosures, kbJwt].join( + SD_JWT_SEPARATOR + ); +} + +/** + * Verify an SD-JWT VP and return disclosed claims and evidence. + * + * @param sdJwtVp - The SD-JWT VP string. + * @param issuerPublicKey - Issuer's public key (for VC verification). + * @param holderPublicKey - Holder's public key (for VP and KB-JWT verification). + * @param options - Expected nonce and audience. + * @returns Verified result with credential claims, evidence, holder, etc. + */ +export async function verifySdJwtVp( + sdJwtVp: string, + issuerPublicKey: CryptoKey, + holderPublicKey: CryptoKey, + options: VerifySdJwtVpOptions = {} +): Promise { + const parts = sdJwtVp.split(SD_JWT_SEPARATOR); + if (parts.length < 3) { + throw new VerificationError("Invalid SD-JWT VP format: too few parts"); + } + + const vpJwt = parts[0]; + const issuerJwt = parts[1]; + const kbJwt = parts[parts.length - 1]; + const disclosures = parts.slice(2, -1); + + // 1. Verify VP JWT (holder) + let vpResult; + try { + vpResult = await compactVerify(vpJwt, holderPublicKey); + } catch (e) { + throw new VerificationError( + `VP JWT verification failed: ${e instanceof Error ? e.message : e}` + ); + } + + if (vpResult.protectedHeader.typ !== "vp+sd-jwt") { + throw new VerificationError( + `Unexpected VP typ: expected 'vp+sd-jwt', got '${vpResult.protectedHeader.typ}'` + ); + } + + const vpPayload = JSON.parse(new TextDecoder().decode(vpResult.payload)); + + // 2. Verify issuer JWT (issuer) + let vcResult; + try { + vcResult = await compactVerify(issuerJwt, issuerPublicKey); + } catch (e) { + throw new VerificationError( + `VC JWT verification failed: ${e instanceof Error ? e.message : e}` + ); + } + + if (vcResult.protectedHeader.typ !== "vc+sd-jwt") { + throw new VerificationError( + `Unexpected VC typ: expected 'vc+sd-jwt', got '${vcResult.protectedHeader.typ}'` + ); + } + + const vcPayload = JSON.parse(new TextDecoder().decode(vcResult.payload)); + + // 3. Verify KB-JWT (holder) + let kbResult; + try { + kbResult = await compactVerify(kbJwt, holderPublicKey); + } catch (e) { + throw new VerificationError( + `KB-JWT verification failed: ${e instanceof Error ? e.message : e}` + ); + } + + if (kbResult.protectedHeader.typ !== "kb+jwt") { + throw new VerificationError( + `Unexpected KB-JWT typ: expected 'kb+jwt', got '${kbResult.protectedHeader.typ}'` + ); + } + + const kbPayload = JSON.parse(new TextDecoder().decode(kbResult.payload)); + + // 4. Verify VC hash binding + const expectedVcHash = base64urlEncode( + new Uint8Array( + await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(issuerJwt) + ) + ) + ); + + if (vpPayload._vc_hash !== expectedVcHash) { + throw new VerificationError( + "VC hash mismatch: VP does not bind to presented VC" + ); + } + + // 5. Verify SD hash in KB-JWT + const sdMaterial = + issuerJwt + + SD_JWT_SEPARATOR + + disclosures.join(SD_JWT_SEPARATOR); + const expectedSdHash = base64urlEncode( + new Uint8Array( + await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(sdMaterial) + ) + ) + ); + + if (kbPayload.sd_hash !== expectedSdHash) { + throw new VerificationError("SD hash mismatch in KB-JWT"); + } + + // 6. Verify nonce + if (options.expectedNonce !== undefined) { + if (vpPayload.nonce !== options.expectedNonce) { + throw new VerificationError( + `Nonce mismatch: expected '${options.expectedNonce}', got '${vpPayload.nonce}'` + ); + } + if (kbPayload.nonce !== options.expectedNonce) { + throw new VerificationError("Nonce mismatch in KB-JWT"); + } + } + + // 7. Verify audience + if (options.expectedAudience !== undefined) { + if (vpPayload.aud !== options.expectedAudience) { + throw new VerificationError( + `Audience mismatch: expected '${options.expectedAudience}', got '${vpPayload.aud}'` + ); + } + if (kbPayload.aud !== options.expectedAudience) { + throw new VerificationError("Audience mismatch in KB-JWT"); + } + } + + // 8. Process disclosures + const sdDigests = new Set(vcPayload._sd ?? []); + const disclosedClaims: Record = {}; + for (const [k, v] of Object.entries(vcPayload)) { + if (k !== "_sd" && k !== "_sd_alg") { + disclosedClaims[k] = v; + } + } + + for (const discB64 of disclosures) { + const discHash = base64urlEncode( + new Uint8Array( + await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(discB64) + ) + ) + ); + + if (!sdDigests.has(discHash)) { + throw new VerificationError( + `Disclosure hash ${discHash.slice(0, 16)}... not found in _sd digests` + ); + } + sdDigests.delete(discHash); + + const discJson = JSON.parse( + new TextDecoder().decode(base64urlDecode(discB64)) + ); + if (!Array.isArray(discJson) || discJson.length !== 3) { + throw new VerificationError( + "Invalid disclosure format: expected [salt, name, value]" + ); + } + const [, claimName, claimValue] = discJson; + disclosedClaims[claimName as string] = claimValue; + } + + // Build result + const vpObj = (vpPayload.vp ?? {}) as Record; + const result: SdJwtVpResult = { + credential: disclosedClaims, + }; + + if (vpObj.holder) result.holder = vpObj.holder as string; + if (vpObj.evidence) + result.evidence = vpObj.evidence as Record[]; + if (vpPayload.nonce) result.nonce = vpPayload.nonce as string; + if (vpPayload.aud) result.audience = vpPayload.aud as string; + + return result; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function resolveAlg(key: CryptoKey): string { + if (key.algorithm.name === "ECDSA") return "ES256"; + if (key.algorithm.name === "Ed25519") return "EdDSA"; + throw new Error(`Unsupported algorithm: ${key.algorithm.name}`); +} + +function base64urlEncode(bytes: Uint8Array): string { + return Buffer.from(bytes) + .toString("base64url") + .replace(/=+$/, ""); +} + +function base64urlDecode(s: string): Uint8Array { + return new Uint8Array(Buffer.from(s, "base64url")); +} diff --git a/tests/fixtures/canonicalization-vectors.json b/tests/fixtures/canonicalization-vectors.json new file mode 100644 index 0000000..e4027bf --- /dev/null +++ b/tests/fixtures/canonicalization-vectors.json @@ -0,0 +1,62 @@ +{ + "description": "Shared test vectors for cross-runtime canonicalization. Python json.dumps(sort_keys=True, separators=(',',':')) sorts ALL keys recursively. TypeScript must match this exactly.", + "vectors": [ + { + "name": "data.purchase — minimal required fields", + "input": { + "type": "harbour_delegate:data.purchase", + "credential_ids": ["simpulse_id"], + "transaction_data_hashes_alg": ["sha-256"], + "nonce": "da9b1009", + "iat": 1771934400, + "txn": { + "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "price": "100", + "currency": "ENVITED", + "marketplace": "did:web:dataspace.envited.io" + } + }, + "canonical_json": "{\"credential_ids\":[\"simpulse_id\"],\"iat\":1771934400,\"nonce\":\"da9b1009\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"assetId\":\"urn:uuid:550e8400-e29b-41d4-a716-446655440000\",\"currency\":\"ENVITED\",\"marketplace\":\"did:web:dataspace.envited.io\",\"price\":\"100\"},\"type\":\"harbour_delegate:data.purchase\"}", + "sha256_hash": "86a3e927a80d9e858a71b37f574aa65cab184c5fc65e8c7824771f84ad6ed97e", + "challenge": "da9b1009 HARBOUR_DELEGATE 86a3e927a80d9e858a71b37f574aa65cab184c5fc65e8c7824771f84ad6ed97e" + }, + { + "name": "contract.sign — with optional exp and description", + "input": { + "type": "harbour_delegate:contract.sign", + "credential_ids": ["org_credential"], + "transaction_data_hashes_alg": ["sha-256"], + "nonce": "ab12cd34", + "iat": 1771934400, + "exp": 1771935300, + "description": "Sign partnership agreement", + "txn": { + "documentHash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "parties": ["did:web:alice.example", "did:web:bob.example"] + } + }, + "canonical_json": "{\"credential_ids\":[\"org_credential\"],\"description\":\"Sign partnership agreement\",\"exp\":1771935300,\"iat\":1771934400,\"nonce\":\"ab12cd34\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"documentHash\":\"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"parties\":[\"did:web:alice.example\",\"did:web:bob.example\"]},\"type\":\"harbour_delegate:contract.sign\"}", + "sha256_hash": "daccac20a99de56e2a5d108fcfa53d0d03faa7d4cd29552ae1dbdc486120d3ec", + "challenge": "ab12cd34 HARBOUR_DELEGATE daccac20a99de56e2a5d108fcfa53d0d03faa7d4cd29552ae1dbdc486120d3ec" + }, + { + "name": "blockchain.transfer — nested txn verifies recursive sort", + "input": { + "type": "harbour_delegate:blockchain.transfer", + "credential_ids": ["default"], + "nonce": "ef567890", + "iat": 1771934400, + "transaction_data_hashes_alg": ["sha-256"], + "txn": { + "chain": "eip155:42793", + "amount": "1000000000000000000", + "recipient": "0xabcdef1234567890", + "contract": "0x1234567890abcdef" + } + }, + "canonical_json": "{\"credential_ids\":[\"default\"],\"iat\":1771934400,\"nonce\":\"ef567890\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"amount\":\"1000000000000000000\",\"chain\":\"eip155:42793\",\"contract\":\"0x1234567890abcdef\",\"recipient\":\"0xabcdef1234567890\"},\"type\":\"harbour_delegate:blockchain.transfer\"}", + "sha256_hash": "0736db89c15be412294f96717a3e435f89d095e7e953b1808c422252b845d4c1", + "challenge": "ef567890 HARBOUR_DELEGATE 0736db89c15be412294f96717a3e435f89d095e7e953b1808c422252b845d4c1" + } + ] +} diff --git a/tests/interop/test_cross_runtime.py b/tests/interop/test_cross_runtime.py index c4095de..1152751 100644 --- a/tests/interop/test_cross_runtime.py +++ b/tests/interop/test_cross_runtime.py @@ -5,6 +5,10 @@ from pathlib import Path import pytest +from harbour.delegation import ( + TransactionData, + create_delegation_challenge, +) from harbour.sd_jwt import issue_sd_jwt_vc, verify_sd_jwt_vc from harbour.signer import sign_vc_jose, sign_vp_jose from harbour.verifier import verify_vc_jose, verify_vp_jose @@ -212,3 +216,121 @@ def test_sd_jwt_from_node(self, p256_public_key): result = verify_sd_jwt_vc(sd_jwt, p256_public_key) assert result["iss"] == "did:web:node-issuer" assert result["vct"] == "https://example.com/vc" + + +class TestCanonicalizationInterop: + """Verify Python and TypeScript produce identical canonical JSON and hashes.""" + + @pytest.fixture() + def vectors(self): + vectors_path = FIXTURES_DIR / "canonicalization-vectors.json" + return json.loads(vectors_path.read_text())["vectors"] + + def test_canonical_json_matches(self, vectors): + """Both runtimes produce the same canonical JSON for all vectors.""" + for v in vectors: + td = TransactionData.from_dict(v["input"]) + py_canonical = td.to_json(canonical=True) + assert ( + py_canonical == v["canonical_json"] + ), f"Python mismatch for {v['name']}" + + # Run all vectors through TypeScript in a single Node invocation + inputs_json = json.dumps([v["input"] for v in vectors]) + expected_json = json.dumps([v["canonical_json"] for v in vectors]) + + script = f""" +import {{ toCanonicalJson }} from "./dist/delegation.js"; +const inputs = {inputs_json}; +const expected = {expected_json}; +const results = inputs.map(input => toCanonicalJson(input)); +for (let i = 0; i < results.length; i++) {{ + if (results[i] !== expected[i]) {{ + console.error("MISMATCH at index " + i); + console.error("Got: " + results[i]); + console.error("Expected: " + expected[i]); + process.exit(1); + }} +}} +console.log("OK"); +""" + assert _run_node(script) == "OK" + + def test_sha256_hash_matches(self, vectors): + """Both runtimes produce the same SHA-256 hash for all vectors.""" + inputs_json = json.dumps([v["input"] for v in vectors]) + expected_json = json.dumps([v["sha256_hash"] for v in vectors]) + + script = f""" +import {{ computeTransactionHash }} from "./dist/delegation.js"; +const inputs = {inputs_json}; +const expected = {expected_json}; +for (let i = 0; i < inputs.length; i++) {{ + const hash = await computeTransactionHash(inputs[i]); + if (hash !== expected[i]) {{ + console.error("MISMATCH at index " + i); + console.error("Got: " + hash); + console.error("Expected: " + expected[i]); + process.exit(1); + }} +}} +console.log("OK"); +""" + assert _run_node(script) == "OK" + + def test_challenge_string_matches(self, vectors): + """Both runtimes produce the same delegation challenge string.""" + inputs_json = json.dumps([v["input"] for v in vectors]) + expected_json = json.dumps([v["challenge"] for v in vectors]) + + script = f""" +import {{ createDelegationChallenge }} from "./dist/delegation.js"; +const inputs = {inputs_json}; +const expected = {expected_json}; +for (let i = 0; i < inputs.length; i++) {{ + const challenge = await createDelegationChallenge(inputs[i]); + if (challenge !== expected[i]) {{ + console.error("MISMATCH at index " + i); + console.error("Got: " + challenge); + console.error("Expected: " + expected[i]); + process.exit(1); + }} +}} +console.log("OK"); +""" + assert _run_node(script) == "OK" + + def test_python_challenge_verified_by_typescript(self, vectors): + """Python-generated challenge is verified by TypeScript.""" + for v in vectors: + td = TransactionData.from_dict(v["input"]) + py_challenge = create_delegation_challenge(td) + input_json = json.dumps(v["input"]) + + script = f""" +import {{ verifyChallenge }} from "./dist/delegation.js"; +const td = {input_json}; +const ok = await verifyChallenge("{py_challenge}", td); +if (!ok) {{ + console.error("Challenge verification failed"); + process.exit(1); +}} +console.log("OK"); +""" + assert _run_node(script) == "OK", f"Failed for {v['name']}" + + def test_typescript_challenge_verified_by_python(self, vectors): + """TypeScript-generated challenge is verified by Python.""" + for v in vectors: + input_json = json.dumps(v["input"]) + + script = f""" +import {{ createDelegationChallenge }} from "./dist/delegation.js"; +const td = {input_json}; +const challenge = await createDelegationChallenge(td); +console.log(challenge); +""" + ts_challenge = _run_node(script) + td = TransactionData.from_dict(v["input"]) + py_challenge = create_delegation_challenge(td) + assert ts_challenge == py_challenge, f"Mismatch for {v['name']}" diff --git a/tests/python/credentials/test_claim_mapping.py b/tests/python/credentials/test_claim_mapping.py index f05e498..9a3dc38 100644 --- a/tests/python/credentials/test_claim_mapping.py +++ b/tests/python/credentials/test_claim_mapping.py @@ -98,7 +98,7 @@ def test_has_evidence(self): vc = _load_fixture("natural-person-credential.json") assert "evidence" in vc evidence = vc["evidence"][0] - assert evidence["type"] == "harbour:EmailVerification" + assert evidence["type"] == "harbour:CredentialEvidence" def test_subject_is_harbour_natural_person(self): """Verify the subject uses harbour:NaturalPerson (outer node only).""" diff --git a/tests/python/credentials/test_example_signer.py b/tests/python/credentials/test_example_signer.py index 24ad232..3ad9f49 100644 --- a/tests/python/credentials/test_example_signer.py +++ b/tests/python/credentials/test_example_signer.py @@ -182,11 +182,44 @@ def test_process_example_without_evidence(self, signing_key, tmp_path): vc_payload = verify_vc_jose(vc_jwt, public_key) assert "harbour:ServiceOfferingCredential" in vc_payload["type"] + def test_process_delegated_signing_receipt(self, signing_key, tmp_path): + """Process the delegated signing receipt with DelegatedSignatureEvidence.""" + private_key, public_key, kid = signing_key + + example_path = EXAMPLES_DIR / "delegated-signing-receipt.json" + if not example_path.exists(): + pytest.skip("examples/ not populated") + + output_dir = tmp_path / "signed" + jwt_path = process_example(example_path, private_key, kid, output_dir) + + # Verify output files exist + assert jwt_path.exists() + assert (output_dir / "delegated-signing-receipt.decoded.json").exists() + assert (output_dir / "delegated-signing-receipt.evidence-vp.jwt").exists() + + # Verify outer VC JWT + vc_jwt = jwt_path.read_text().strip() + vc_payload = verify_vc_jose(vc_jwt, public_key) + assert "harbour:DelegatedSigningReceipt" in vc_payload["type"] + + # Evidence should contain DelegatedSignatureEvidence with transactionData + evidence = vc_payload["evidence"][0] + assert evidence["type"] == "harbour:DelegatedSignatureEvidence" + assert "transactionData" in evidence + assert evidence["transactionData"]["type"] == "harbour_delegate:data.purchase" + assert evidence["delegatedTo"] == "did:web:signing-service.envited.io" + + # Evidence VP should be a signed JWT + vp_jwt_str = evidence["verifiablePresentation"] + assert isinstance(vp_jwt_str, str) + assert vp_jwt_str.count(".") == 2 + def test_process_all_examples(self, signing_key, tmp_path): """Process all examples and verify each produces a valid JWT.""" private_key, public_key, kid = signing_key - example_files = sorted(EXAMPLES_DIR.glob("*.json")) + example_files = sorted(EXAMPLES_DIR.glob("*-credential.json")) if not example_files: pytest.skip("examples/ not populated") diff --git a/tests/python/credentials/test_validation.py b/tests/python/credentials/test_validation.py index d9c0230..c6020d7 100644 --- a/tests/python/credentials/test_validation.py +++ b/tests/python/credentials/test_validation.py @@ -36,9 +36,13 @@ def _load_json(path: Path) -> dict: def _all_credential_files() -> list[Path]: - """Collect all credential JSON files from examples/.""" + """Collect all credential JSON files from examples/ (VCs only, not VPs).""" if EXAMPLES_DIR.is_dir(): - return sorted(EXAMPLES_DIR.glob("*.json")) + return sorted( + p + for p in EXAMPLES_DIR.glob("*.json") + if any(t in p.stem for t in ("credential", "receipt", "offering")) + ) return [] @@ -144,8 +148,8 @@ def test_context_has_base_classes(self): base_classes = [ "HarbourCredential", "CRSetEntry", - "EmailVerification", - "IssuanceEvidence", + "CredentialEvidence", + "DelegatedSignatureEvidence", ] for cls in base_classes: assert cls in ctx, f"Missing {cls} in harbour base context" @@ -154,8 +158,8 @@ def test_base_class_iris_are_prefixed(self): ctx = _load_json(HARBOUR_CONTEXT_PATH).get("@context", {}) base_classes = [ "CRSetEntry", - "EmailVerification", - "IssuanceEvidence", + "CredentialEvidence", + "DelegatedSignatureEvidence", ] has_vocab = "@vocab" in ctx for cls in base_classes: @@ -240,8 +244,8 @@ def test_shacl_has_base_shapes(self): expected_shapes = [ "harbour:HarbourCredential", "harbour:CRSetEntry", - "harbour:EmailVerification", - "harbour:IssuanceEvidence", + "harbour:CredentialEvidence", + "harbour:DelegatedSignatureEvidence", ] for shape in expected_shapes: assert ( @@ -265,7 +269,7 @@ def test_harbour_credential_shape_has_issuer(self): def test_evidence_shapes_require_verifiable_presentation(self): """Evidence shapes must require verifiablePresentation.""" content = HARBOUR_SHACL_PATH.read_text() - for ev_type in ["EmailVerification", "IssuanceEvidence"]: + for ev_type in ["CredentialEvidence", "DelegatedSignatureEvidence"]: marker = f"harbour:{ev_type} a sh:NodeShape" shape_start = content.index(marker) next_shape = content.find("\n\n", shape_start + 1) diff --git a/tests/python/harbour/test_delegation.py b/tests/python/harbour/test_delegation.py index b6bf954..9072389 100644 --- a/tests/python/harbour/test_delegation.py +++ b/tests/python/harbour/test_delegation.py @@ -1,19 +1,23 @@ """Tests for harbour.delegation module. -This module tests the Harbour Delegated Signing Evidence Specification v2. +This module tests the Harbour Delegated Signing Evidence Specification v2 +with OID4VP-aligned TransactionData. Tests cover: -- TransactionData creation and serialization +- TransactionData creation and serialization (OID4VP fields) - Challenge creation and parsing - Hash computation determinism - Challenge verification - Validation (timestamp, nonce, expiration) - Human-readable display rendering +- Shared canonicalization test vectors """ from __future__ import annotations -from datetime import datetime, timedelta, timezone +import json +import time +from pathlib import Path from unittest.mock import patch import pytest @@ -29,6 +33,9 @@ verify_challenge, ) +FIXTURES_DIR = Path(__file__).resolve().parents[2] / "fixtures" + + # ============================================================================= # TransactionData Tests # ============================================================================= @@ -41,78 +48,105 @@ def test_create_basic(self): """Test basic TransactionData creation.""" tx = TransactionData.create( action="data.purchase", - transaction={"assetId": "urn:uuid:test", "price": "100"}, + txn={"assetId": "urn:uuid:test", "price": "100"}, ) - assert tx.action == "data.purchase" - assert tx.type == "HarbourDelegatedTransaction" - assert tx.version == "1.0" - assert tx.transaction == {"assetId": "urn:uuid:test", "price": "100"} - assert tx.metadata == {} + assert tx.type == "harbour_delegate:data.purchase" + assert tx.credential_ids == ["default"] + assert tx.txn == {"assetId": "urn:uuid:test", "price": "100"} + assert tx.exp is None + assert tx.description is None + assert tx.transaction_data_hashes_alg == ["sha-256"] assert len(tx.nonce) == 8 # Default hex nonce is 8 chars - assert tx.timestamp.endswith("Z") + assert isinstance(tx.iat, int) def test_create_with_custom_nonce(self): """Test TransactionData with custom nonce.""" tx = TransactionData.create( action="data.purchase", - transaction={"assetId": "test"}, + txn={"assetId": "test"}, nonce="custom123", ) assert tx.nonce == "custom123" - def test_create_with_custom_timestamp(self): - """Test TransactionData with custom timestamp.""" - ts = datetime(2026, 2, 24, 12, 0, 0, tzinfo=timezone.utc) + def test_create_with_custom_iat(self): + """Test TransactionData with custom iat.""" tx = TransactionData.create( action="data.purchase", - transaction={"assetId": "test"}, - timestamp=ts, + txn={"assetId": "test"}, + iat=1771934400, ) - assert tx.timestamp == "2026-02-24T12:00:00Z" + assert tx.iat == 1771934400 - def test_create_with_metadata(self): - """Test TransactionData with metadata.""" + def test_create_with_optional_fields(self): + """Test TransactionData with optional fields.""" tx = TransactionData.create( action="data.purchase", - transaction={"assetId": "test"}, - metadata={ - "description": "Test purchase", - "expiresAt": "2026-02-24T13:00:00Z", - }, + txn={"assetId": "test"}, + exp=1771935300, + description="Test purchase", + credential_ids=["simpulse_id"], ) - assert tx.metadata["description"] == "Test purchase" - assert tx.metadata["expiresAt"] == "2026-02-24T13:00:00Z" + assert tx.exp == 1771935300 + assert tx.description == "Test purchase" + assert tx.credential_ids == ["simpulse_id"] - def test_to_dict(self): - """Test TransactionData.to_dict().""" - tx = TransactionData( + def test_action_property(self): + """Test action extraction from type field.""" + tx = TransactionData.create( action="data.purchase", - timestamp="2026-02-24T12:00:00Z", + txn={"assetId": "test"}, + ) + + assert tx.action == "data.purchase" + + def test_to_dict_omits_none(self): + """Test TransactionData.to_dict() omits None values.""" + tx = TransactionData( + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={"assetId": "test", "price": "100"}, + iat=1771934400, + txn={"assetId": "test", "price": "100"}, ) d = tx.to_dict() - assert d["type"] == "HarbourDelegatedTransaction" - assert d["version"] == "1.0" - assert d["action"] == "data.purchase" - assert d["timestamp"] == "2026-02-24T12:00:00Z" + assert d["type"] == "harbour_delegate:data.purchase" + assert d["credential_ids"] == ["default"] assert d["nonce"] == "da9b1009" - assert d["transaction"] == {"assetId": "test", "price": "100"} - assert d["metadata"] == {} + assert d["iat"] == 1771934400 + assert d["txn"] == {"assetId": "test", "price": "100"} + assert "exp" not in d + assert "description" not in d + + def test_to_dict_includes_optional_when_present(self): + """Test TransactionData.to_dict() includes optional fields when set.""" + tx = TransactionData( + type="harbour_delegate:data.purchase", + credential_ids=["simpulse_id"], + nonce="da9b1009", + iat=1771934400, + txn={"assetId": "test"}, + exp=1771935300, + description="Test purchase", + ) + + d = tx.to_dict() + assert d["exp"] == 1771935300 + assert d["description"] == "Test purchase" def test_to_json_canonical(self): """Test canonical JSON output (sorted keys, no whitespace).""" tx = TransactionData( - action="data.purchase", - timestamp="2026-02-24T12:00:00Z", + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={"zzzField": "last", "aaaField": "first"}, + iat=1771934400, + txn={"zzzField": "last", "aaaField": "first"}, ) json_str = tx.to_json(canonical=True) @@ -127,10 +161,11 @@ def test_to_json_canonical(self): def test_to_json_pretty(self): """Test pretty JSON output.""" tx = TransactionData( - action="data.purchase", - timestamp="2026-02-24T12:00:00Z", + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={"assetId": "test"}, + iat=1771934400, + txn={"assetId": "test"}, ) json_str = tx.to_json(canonical=False) @@ -142,27 +177,37 @@ def test_to_json_pretty(self): def test_from_dict(self): """Test TransactionData.from_dict().""" data = { - "type": "HarbourDelegatedTransaction", - "version": "1.0", - "action": "contract.sign", - "timestamp": "2026-02-24T12:00:00Z", + "type": "harbour_delegate:contract.sign", + "credential_ids": ["org_credential"], "nonce": "ab12cd34", - "transaction": {"documentHash": "sha256:abc123"}, - "metadata": {"expiresAt": "2026-02-24T13:00:00Z"}, + "iat": 1771934400, + "exp": 1771935300, + "description": "Sign agreement", + "txn": {"documentHash": "sha256:abc123"}, + "transaction_data_hashes_alg": ["sha-256"], } tx = TransactionData.from_dict(data) - assert tx.type == "HarbourDelegatedTransaction" - assert tx.version == "1.0" - assert tx.action == "contract.sign" + assert tx.type == "harbour_delegate:contract.sign" + assert tx.credential_ids == ["org_credential"] assert tx.nonce == "ab12cd34" - assert tx.transaction["documentHash"] == "sha256:abc123" - assert tx.metadata["expiresAt"] == "2026-02-24T13:00:00Z" + assert tx.iat == 1771934400 + assert tx.exp == 1771935300 + assert tx.description == "Sign agreement" + assert tx.txn["documentHash"] == "sha256:abc123" def test_from_json(self): """Test TransactionData.from_json().""" - json_str = '{"action":"data.purchase","nonce":"abc12345","timestamp":"2026-02-24T12:00:00Z","transaction":{"assetId":"test"},"type":"HarbourDelegatedTransaction","version":"1.0"}' + json_str = json.dumps( + { + "type": "harbour_delegate:data.purchase", + "credential_ids": ["default"], + "nonce": "abc12345", + "iat": 1771934400, + "txn": {"assetId": "test"}, + } + ) tx = TransactionData.from_json(json_str) @@ -173,19 +218,22 @@ def test_round_trip(self): """Test serialization round-trip preserves data.""" original = TransactionData.create( action="blockchain.transfer", - transaction={"recipient": "0xabc", "amount": "1000"}, - metadata={"description": "Test transfer"}, + txn={"recipient": "0xabc", "amount": "1000"}, + description="Test transfer", + credential_ids=["wallet_cred"], ) # Round-trip through JSON json_str = original.to_json(canonical=True) restored = TransactionData.from_json(json_str) + assert restored.type == original.type assert restored.action == original.action assert restored.nonce == original.nonce - assert restored.timestamp == original.timestamp - assert restored.transaction == original.transaction - assert restored.metadata == original.metadata + assert restored.iat == original.iat + assert restored.txn == original.txn + assert restored.description == original.description + assert restored.credential_ids == original.credential_ids class TestHashComputation: @@ -194,17 +242,19 @@ class TestHashComputation: def test_compute_hash_deterministic(self): """Test that hash computation is deterministic.""" tx1 = TransactionData( - action="data.purchase", - timestamp="2026-02-24T12:00:00Z", + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={"assetId": "test", "price": "100"}, + iat=1771934400, + txn={"assetId": "test", "price": "100"}, ) tx2 = TransactionData( - action="data.purchase", - timestamp="2026-02-24T12:00:00Z", + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={"assetId": "test", "price": "100"}, + iat=1771934400, + txn={"assetId": "test", "price": "100"}, ) assert tx1.compute_hash() == tx2.compute_hash() @@ -212,17 +262,19 @@ def test_compute_hash_deterministic(self): def test_compute_hash_key_order_independent(self): """Test that hash is independent of transaction dict key order.""" tx1 = TransactionData( - action="data.purchase", - timestamp="2026-02-24T12:00:00Z", + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={"assetId": "test", "price": "100"}, + iat=1771934400, + txn={"assetId": "test", "price": "100"}, ) tx2 = TransactionData( - action="data.purchase", - timestamp="2026-02-24T12:00:00Z", + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={"price": "100", "assetId": "test"}, # Different order + iat=1771934400, + txn={"price": "100", "assetId": "test"}, # Different order ) # Hashes should be equal since canonical JSON sorts keys @@ -232,7 +284,7 @@ def test_compute_hash_64_hex_chars(self): """Test that hash is 64 hex characters (SHA-256).""" tx = TransactionData.create( action="data.purchase", - transaction={"assetId": "test"}, + txn={"assetId": "test"}, ) hash_value = tx.compute_hash() @@ -243,17 +295,19 @@ def test_compute_hash_64_hex_chars(self): def test_compute_hash_changes_with_data(self): """Test that hash changes when data changes.""" tx1 = TransactionData( - action="data.purchase", - timestamp="2026-02-24T12:00:00Z", + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={"assetId": "test", "price": "100"}, + iat=1771934400, + txn={"assetId": "test", "price": "100"}, ) tx2 = TransactionData( - action="data.purchase", - timestamp="2026-02-24T12:00:00Z", + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={"assetId": "test", "price": "200"}, # Different price + iat=1771934400, + txn={"assetId": "test", "price": "200"}, # Different price ) assert tx1.compute_hash() != tx2.compute_hash() @@ -261,10 +315,11 @@ def test_compute_hash_changes_with_data(self): def test_compute_hash_sensitive_to_all_fields(self): """Test that hash changes for any field change.""" base = { - "action": "data.purchase", - "timestamp": "2026-02-24T12:00:00Z", + "type": "harbour_delegate:data.purchase", + "credential_ids": ["default"], "nonce": "da9b1009", - "transaction": {"assetId": "test"}, + "iat": 1771934400, + "txn": {"assetId": "test"}, } base_tx = TransactionData(**base) @@ -272,10 +327,11 @@ def test_compute_hash_sensitive_to_all_fields(self): # Test each field change produces different hash variations = [ - {"action": "data.share"}, - {"timestamp": "2026-02-24T13:00:00Z"}, + {"type": "harbour_delegate:data.share"}, + {"credential_ids": ["other"]}, {"nonce": "different"}, - {"transaction": {"assetId": "other"}}, + {"iat": 9999999999}, + {"txn": {"assetId": "other"}}, ] for change in variations: @@ -286,6 +342,49 @@ def test_compute_hash_sensitive_to_all_fields(self): ), f"Hash unchanged for {change}" +class TestSharedVectors: + """Tests using shared canonicalization test vectors.""" + + @pytest.fixture + def vectors(self): + """Load shared test vectors.""" + vectors_path = FIXTURES_DIR / "canonicalization-vectors.json" + return json.loads(vectors_path.read_text())["vectors"] + + def test_canonical_json_matches(self, vectors): + """Test that Python canonical JSON matches expected output.""" + for v in vectors: + tx = TransactionData.from_dict(v["input"]) + canonical = tx.to_json(canonical=True) + assert canonical == v["canonical_json"], ( + f"Canonical JSON mismatch for '{v['name']}':\n" + f" got: {canonical}\n" + f" expected: {v['canonical_json']}" + ) + + def test_sha256_hash_matches(self, vectors): + """Test that Python SHA-256 hash matches expected output.""" + for v in vectors: + tx = TransactionData.from_dict(v["input"]) + hash_value = tx.compute_hash() + assert hash_value == v["sha256_hash"], ( + f"SHA-256 hash mismatch for '{v['name']}':\n" + f" got: {hash_value}\n" + f" expected: {v['sha256_hash']}" + ) + + def test_challenge_matches(self, vectors): + """Test that challenge string matches expected output.""" + for v in vectors: + tx = TransactionData.from_dict(v["input"]) + challenge = create_delegation_challenge(tx) + assert challenge == v["challenge"], ( + f"Challenge mismatch for '{v['name']}':\n" + f" got: {challenge}\n" + f" expected: {v['challenge']}" + ) + + # ============================================================================= # Challenge Creation Tests # ============================================================================= @@ -297,10 +396,11 @@ class TestCreateDelegationChallenge: def test_basic_challenge(self): """Test basic challenge creation.""" tx = TransactionData( - action="data.purchase", - timestamp="2026-02-24T12:00:00Z", + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={"assetId": "test", "price": "100"}, + iat=1771934400, + txn={"assetId": "test", "price": "100"}, ) challenge = create_delegation_challenge(tx) @@ -314,10 +414,11 @@ def test_basic_challenge(self): def test_challenge_matches_hash(self): """Test that challenge hash matches computed hash.""" tx = TransactionData( - action="data.purchase", - timestamp="2026-02-24T12:00:00Z", + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={"assetId": "test"}, + iat=1771934400, + txn={"assetId": "test"}, ) challenge = create_delegation_challenge(tx) @@ -382,7 +483,7 @@ def test_round_trip(self): """Test create -> parse round-trip.""" tx = TransactionData.create( action="data.purchase", - transaction={"assetId": "test"}, + txn={"assetId": "test"}, ) challenge = create_delegation_challenge(tx) @@ -404,10 +505,11 @@ class TestVerifyChallenge: def test_verify_matching_challenge(self): """Test verification of matching challenge.""" tx = TransactionData( - action="data.purchase", - timestamp="2026-02-24T12:00:00Z", + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={"assetId": "test"}, + iat=1771934400, + txn={"assetId": "test"}, ) challenge = create_delegation_challenge(tx) @@ -417,10 +519,11 @@ def test_verify_matching_challenge(self): def test_verify_mismatched_nonce(self): """Test verification fails for mismatched nonce.""" tx = TransactionData( - action="data.purchase", - timestamp="2026-02-24T12:00:00Z", + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={"assetId": "test"}, + iat=1771934400, + txn={"assetId": "test"}, ) # Create challenge with different nonce @@ -431,10 +534,11 @@ def test_verify_mismatched_nonce(self): def test_verify_mismatched_hash(self): """Test verification fails for mismatched hash.""" tx = TransactionData( - action="data.purchase", - timestamp="2026-02-24T12:00:00Z", + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={"assetId": "test"}, + iat=1771934400, + txn={"assetId": "test"}, ) # Create challenge with wrong hash @@ -445,16 +549,17 @@ def test_verify_mismatched_hash(self): def test_verify_tampered_data(self): """Test verification fails for tampered transaction data.""" tx = TransactionData( - action="data.purchase", - timestamp="2026-02-24T12:00:00Z", + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={"assetId": "test", "price": "100"}, + iat=1771934400, + txn={"assetId": "test", "price": "100"}, ) challenge = create_delegation_challenge(tx) # Tamper with transaction data - tx.transaction["price"] = "999" + tx.txn["price"] = "999" assert verify_challenge(challenge, tx) is False @@ -471,20 +576,20 @@ def test_validate_valid_transaction(self): """Test validation of valid transaction.""" tx = TransactionData.create( action="data.purchase", - transaction={"assetId": "test"}, + txn={"assetId": "test"}, ) # Should not raise validate_transaction_data(tx) def test_validate_invalid_type(self): - """Test validation fails for invalid type.""" + """Test validation fails for invalid type prefix.""" tx = TransactionData( - action="data.purchase", - timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + type="wrong_prefix:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={"assetId": "test"}, - type="WrongType", + iat=int(time.time()), + txn={"assetId": "test"}, ) with pytest.raises(ChallengeError) as excinfo: @@ -495,10 +600,11 @@ def test_validate_invalid_type(self): def test_validate_short_nonce(self): """Test validation fails for short nonce.""" tx = TransactionData( - action="data.purchase", - timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="abc", # Too short (< 8 chars) - transaction={"assetId": "test"}, + iat=int(time.time()), + txn={"assetId": "test"}, ) with pytest.raises(ChallengeError) as excinfo: @@ -508,12 +614,13 @@ def test_validate_short_nonce(self): def test_validate_old_timestamp(self): """Test validation fails for old timestamp.""" - old_time = datetime.now(timezone.utc) - timedelta(minutes=10) + old_iat = int(time.time()) - 600 # 10 minutes ago tx = TransactionData( - action="data.purchase", - timestamp=old_time.strftime("%Y-%m-%dT%H:%M:%SZ"), + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={"assetId": "test"}, + iat=old_iat, + txn={"assetId": "test"}, ) with pytest.raises(ChallengeError) as excinfo: @@ -523,12 +630,13 @@ def test_validate_old_timestamp(self): def test_validate_future_timestamp(self): """Test validation fails for future timestamp.""" - future_time = datetime.now(timezone.utc) + timedelta(minutes=5) + future_iat = int(time.time()) + 300 # 5 minutes in future tx = TransactionData( - action="data.purchase", - timestamp=future_time.strftime("%Y-%m-%dT%H:%M:%SZ"), + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={"assetId": "test"}, + iat=future_iat, + txn={"assetId": "test"}, ) with pytest.raises(ChallengeError) as excinfo: @@ -538,11 +646,11 @@ def test_validate_future_timestamp(self): def test_validate_expired_transaction(self): """Test validation fails for expired transaction.""" - past_expiry = datetime.now(timezone.utc) - timedelta(minutes=5) + now = int(time.time()) tx = TransactionData.create( action="data.purchase", - transaction={"assetId": "test"}, - metadata={"expiresAt": past_expiry.strftime("%Y-%m-%dT%H:%M:%SZ")}, + txn={"assetId": "test"}, + exp=now - 300, # Expired 5 minutes ago ) with pytest.raises(ChallengeError) as excinfo: @@ -553,12 +661,13 @@ def test_validate_expired_transaction(self): def test_validate_custom_max_age(self): """Test validation with custom max age.""" # 2 minutes old - old_time = datetime.now(timezone.utc) - timedelta(seconds=120) + old_iat = int(time.time()) - 120 tx = TransactionData( - action="data.purchase", - timestamp=old_time.strftime("%Y-%m-%dT%H:%M:%SZ"), + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={"assetId": "test"}, + iat=old_iat, + txn={"assetId": "test"}, ) # Should fail with 60s max age @@ -580,10 +689,11 @@ class TestRenderTransactionDisplay: def test_render_basic(self): """Test basic display rendering.""" tx = TransactionData( - action="data.purchase", - timestamp="2026-02-24T12:00:00Z", + type="harbour_delegate:data.purchase", + credential_ids=["default"], nonce="da9b1009", - transaction={ + iat=1771934400, + txn={ "assetId": "urn:uuid:test", "price": "100", "currency": "ENVITED", @@ -595,13 +705,13 @@ def test_render_basic(self): assert "requests your authorization" in display assert "Purchase data asset" in display # Human-readable label assert "da9b1009" in display - assert "2026-02-24T12:00:00Z" in display + assert "1771934400" in display def test_render_custom_service_name(self): """Test display with custom service name.""" tx = TransactionData.create( action="data.purchase", - transaction={"assetId": "test"}, + txn={"assetId": "test"}, ) display = render_transaction_display(tx, service_name="Custom Service") @@ -611,10 +721,11 @@ def test_render_custom_service_name(self): def test_render_unknown_action(self): """Test display with unknown action type.""" tx = TransactionData( - action="unknown.action", - timestamp="2026-02-24T12:00:00Z", + type="harbour_delegate:unknown.action", + credential_ids=["default"], nonce="da9b1009", - transaction={"someField": "value"}, + iat=1771934400, + txn={"someField": "value"}, ) display = render_transaction_display(tx) @@ -626,21 +737,21 @@ def test_render_with_expiration(self): """Test display includes expiration if present.""" tx = TransactionData.create( action="data.purchase", - transaction={"assetId": "test"}, - metadata={"expiresAt": "2026-02-24T13:00:00Z"}, + txn={"assetId": "test"}, + exp=1771935300, ) display = render_transaction_display(tx) assert "Expires:" in display - assert "2026-02-24T13:00:00Z" in display + assert "1771935300" in display def test_render_with_description(self): """Test display includes description if present.""" tx = TransactionData.create( action="data.purchase", - transaction={"assetId": "test"}, - metadata={"description": "Purchase sensor data from BMW"}, + txn={"assetId": "test"}, + description="Purchase sensor data from BMW", ) display = render_transaction_display(tx) @@ -652,7 +763,7 @@ def test_render_truncates_long_values(self): """Test display truncates very long values.""" tx = TransactionData.create( action="data.purchase", - transaction={"assetId": "a" * 100}, # Very long value + txn={"assetId": "a" * 100}, # Very long value ) display = render_transaction_display(tx) @@ -665,7 +776,7 @@ def test_render_all_action_labels(self): for action, label in ACTION_LABELS.items(): tx = TransactionData.create( action=action, - transaction={"testField": "value"}, + txn={"testField": "value"}, ) display = render_transaction_display(tx) @@ -762,12 +873,13 @@ def test_full_workflow(self): # 1. Create transaction data tx = TransactionData.create( action="data.purchase", - transaction={ + txn={ "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", }, - metadata={"description": "Purchase sensor data"}, + description="Purchase sensor data", + credential_ids=["simpulse_id"], ) # 2. Create challenge @@ -794,7 +906,7 @@ def test_serialization_workflow(self): # Create and serialize original_tx = TransactionData.create( action="contract.sign", - transaction={"documentHash": "sha256:abc123"}, + txn={"documentHash": "sha256:abc123"}, ) challenge = create_delegation_challenge(original_tx) tx_json = original_tx.to_json() @@ -812,7 +924,7 @@ def test_multiple_transactions_unique_hashes(self): for i in range(10): tx = TransactionData.create( action="data.purchase", - transaction={"assetId": f"asset-{i}"}, + txn={"assetId": f"asset-{i}"}, ) hashes.add(tx.compute_hash()) diff --git a/tests/python/harbour/test_sd_jwt_vp.py b/tests/python/harbour/test_sd_jwt_vp.py index 184e4d3..a737793 100644 --- a/tests/python/harbour/test_sd_jwt_vp.py +++ b/tests/python/harbour/test_sd_jwt_vp.py @@ -30,28 +30,23 @@ def sample_sd_jwt_vc(issuer_keypair, holder_keypair): holder_private, holder_public = holder_keypair holder_did = p256_public_key_to_did_key(holder_public) - credential = { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://www.w3.org/ns/credentials/examples/v2", - ], - "type": ["VerifiableCredential", "MembershipCredential"], - "issuer": "did:web:issuer.example.com", - "credentialSubject": { - "id": holder_did, - "givenName": "Alice", - "familyName": "Smith", - "email": "alice@example.com", - "memberOf": "Example Organization", - "role": "member", - }, + # SD-JWT-VC uses flat claims (not nested credentialSubject) + claims = { + "iss": "did:web:issuer.example.com", + "sub": holder_did, + "givenName": "Alice", + "familyName": "Smith", + "email": "alice@example.com", + "memberOf": "Example Organization", + "role": "member", } # Create SD-JWT-VC with selective disclosure claims sd_jwt_vc = issue_sd_jwt_vc( - credential, + claims, private_key, - sd_claims=["givenName", "familyName", "email"], + vct="https://example.com/MembershipCredential", + disclosable=["givenName", "familyName", "email"], ) return sd_jwt_vc @@ -131,11 +126,12 @@ def test_issue_with_evidence(self, sample_sd_jwt_vc, holder_keypair): evidence = [ { "type": "DelegatedSignatureEvidence", - "transactionIntent": { - "actionType": "purchase", - "actionReference": "tx:abc123", - "consentTimestamp": "2024-01-15T10:30:00Z", - "consentNonce": secrets.token_urlsafe(16), + "transactionData": { + "type": "harbour_delegate:data.purchase", + "credential_ids": ["simpulse_id"], + "nonce": secrets.token_urlsafe(16), + "iat": 1771934400, + "txn": {"assetId": "tx:abc123", "price": "100"}, }, "delegatedTo": "did:web:signing-service.example.com", } @@ -271,10 +267,12 @@ def test_verify_evidence(self, sample_sd_jwt_vc, issuer_keypair, holder_keypair) evidence = [ { "type": "DelegatedSignatureEvidence", - "transactionIntent": { - "actionType": "approve", - "consentTimestamp": "2024-01-15T12:00:00Z", - "consentNonce": "unique-consent-nonce", + "transactionData": { + "type": "harbour_delegate:blockchain.approve", + "credential_ids": ["default"], + "nonce": "unique-consent-nonce", + "iat": 1771934400, + "txn": {"contract": "0x1234"}, }, } ] @@ -372,42 +370,44 @@ def test_delegated_consent_flow(self, issuer_keypair, holder_keypair): holder_private, holder_public = holder_keypair holder_did = p256_public_key_to_did_key(holder_public) - # Step 1: Issue credential to holder - credential = { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiableCredential", "IdentityCredential"], - "issuer": "did:web:trusted-issuer.example.com", - "credentialSubject": { - "id": holder_did, - "givenName": "Carlo", - "familyName": "Rossi", - "organization": "BMW", - "role": "Purchaser", - }, + # Step 1: Issue credential to holder (SD-JWT-VC uses flat claims) + claims = { + "iss": "did:web:trusted-issuer.example.com", + "sub": holder_did, + "givenName": "Carlo", + "familyName": "Rossi", + "organization": "BMW", + "role": "Purchaser", } sd_jwt_vc = issue_sd_jwt_vc( - credential, + claims, issuer_private, - sd_claims=["givenName", "familyName"], # PII is selectively disclosable + vct="https://example.com/IdentityCredential", + disclosable=["givenName", "familyName"], # PII is selectively disclosable ) # Step 2: Holder creates consent VP signing_service_did = "did:web:harbour.signing-service.example.com" consent_nonce = secrets.token_urlsafe(32) - transaction_intent = { - "actionType": "blockchain:purchase", - "actionReference": "tx:0xabc123def456", - "consentTimestamp": "2024-01-15T14:30:00Z", - "consentNonce": consent_nonce, + transaction_data = { + "type": "harbour_delegate:data.purchase", + "credential_ids": ["simpulse_id"], + "nonce": consent_nonce, + "iat": 1771934400, "description": "Purchase data asset XYZ for 100 ENVITED tokens", + "txn": { + "assetId": "tx:0xabc123def456", + "price": "100", + "currency": "ENVITED", + }, } evidence = [ { "type": "DelegatedSignatureEvidence", - "transactionIntent": transaction_intent, + "transactionData": transaction_data, "delegatedTo": signing_service_did, } ] @@ -447,12 +447,12 @@ def test_delegated_consent_flow(self, issuer_keypair, holder_keypair): assert "givenName" not in cred # PII hidden assert "familyName" not in cred # PII hidden - # Evidence should contain transaction intent + # Evidence should contain transaction data assert len(result["evidence"]) == 1 ev = result["evidence"][0] assert ev["type"] == "DelegatedSignatureEvidence" - assert ev["transactionIntent"]["actionType"] == "blockchain:purchase" - assert ev["transactionIntent"]["consentNonce"] == consent_nonce + assert ev["transactionData"]["type"] == "harbour_delegate:data.purchase" + assert ev["transactionData"]["nonce"] == consent_nonce assert ev["delegatedTo"] == signing_service_did def test_public_audit_privacy(self, issuer_keypair, holder_keypair): @@ -461,34 +461,32 @@ def test_public_audit_privacy(self, issuer_keypair, holder_keypair): holder_private, holder_public = holder_keypair holder_did = p256_public_key_to_did_key(holder_public) - # Issue credential with PII - credential = { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiableCredential"], - "issuer": "did:web:issuer.example.com", - "credentialSubject": { - "id": holder_did, - "name": "Confidential Person", - "email": "secret@example.com", - "publicRole": "Authorized Purchaser", - }, + # Issue credential with PII (SD-JWT-VC uses flat claims) + claims = { + "iss": "did:web:issuer.example.com", + "sub": holder_did, + "name": "Confidential Person", + "email": "secret@example.com", + "publicRole": "Authorized Purchaser", } sd_jwt_vc = issue_sd_jwt_vc( - credential, + claims, issuer_private, - sd_claims=["name", "email"], # PII hidden by default + vct="https://example.com/VerifiableCredential", + disclosable=["name", "email"], # PII hidden by default ) # Create VP with no PII disclosed evidence = [ { "type": "DelegatedSignatureEvidence", - "transactionIntent": { - "actionType": "execute:transfer", - "actionReference": "blockchain:tx:0x123", - "consentTimestamp": "2024-01-15T15:00:00Z", - "consentNonce": "public-audit-nonce", + "transactionData": { + "type": "harbour_delegate:blockchain.transfer", + "credential_ids": ["default"], + "nonce": "public-audit-nonce", + "iat": 1771934400, + "txn": {"recipient": "0x123", "amount": "1000"}, }, } ] @@ -546,7 +544,7 @@ def test_empty_evidence_list(self, sample_sd_jwt_vc, holder_keypair): evidence=[], # Empty but not None ) - # Should work but evidence field still present + # Empty evidence list is treated as no evidence (not included in VP) parts = vp.split("~") vp_jwt = parts[0] payload_b64 = vp_jwt.split(".")[1] @@ -554,15 +552,15 @@ def test_empty_evidence_list(self, sample_sd_jwt_vc, holder_keypair): base64.urlsafe_b64decode(payload_b64 + "=" * (-len(payload_b64) % 4)) ) - assert payload["vp"]["evidence"] == [] + assert "evidence" not in payload["vp"] def test_multiple_evidence_items(self, sample_sd_jwt_vc, holder_keypair): """Test VP with multiple evidence items.""" holder_private, _ = holder_keypair evidence = [ - {"type": "DelegatedSignatureEvidence", "transactionIntent": {}}, - {"type": "EmailVerification", "verifiedEmail": "test@example.com"}, + {"type": "DelegatedSignatureEvidence", "transactionData": {}}, + {"type": "CredentialEvidence", "verifiablePresentation": "eyJ..."}, ] vp = issue_sd_jwt_vp( diff --git a/tests/typescript/harbour/delegation.test.ts b/tests/typescript/harbour/delegation.test.ts new file mode 100644 index 0000000..0681eb4 --- /dev/null +++ b/tests/typescript/harbour/delegation.test.ts @@ -0,0 +1,447 @@ +/** + * Tests for harbour delegation module. + * + * Tests cover: + * - TransactionData creation and serialization (OID4VP fields) + * - Challenge creation and parsing + * - Hash computation determinism + * - Challenge verification + * - Validation + * - Human-readable display + * - Shared canonicalization test vectors + */ + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + ACTION_LABELS, + ACTION_TYPE, + TYPE_PREFIX, + ChallengeError, + type TransactionData, + computeTransactionHash, + createDelegationChallenge, + createTransactionData, + getAction, + parseDelegationChallenge, + renderTransactionDisplay, + toCanonicalJson, + validateTransactionData, + verifyChallenge, +} from "../../../src/typescript/harbour/delegation.js"; + +const FIXTURES_DIR = resolve(__dirname, "../../fixtures"); + +// ============================================================================= +// TransactionData Tests +// ============================================================================= + +describe("TransactionData", () => { + it("creates basic transaction data", () => { + const tx = createTransactionData({ + action: "data.purchase", + txn: { assetId: "urn:uuid:test", price: "100" }, + }); + + expect(tx.type).toBe("harbour_delegate:data.purchase"); + expect(tx.credential_ids).toEqual(["default"]); + expect(tx.txn).toEqual({ assetId: "urn:uuid:test", price: "100" }); + expect(tx.exp).toBeUndefined(); + expect(tx.description).toBeUndefined(); + expect(tx.transaction_data_hashes_alg).toEqual(["sha-256"]); + expect(tx.nonce).toHaveLength(8); + expect(typeof tx.iat).toBe("number"); + }); + + it("creates with custom nonce", () => { + const tx = createTransactionData({ + action: "data.purchase", + txn: { assetId: "test" }, + nonce: "custom123", + }); + expect(tx.nonce).toBe("custom123"); + }); + + it("creates with custom iat", () => { + const tx = createTransactionData({ + action: "data.purchase", + txn: { assetId: "test" }, + iat: 1771934400, + }); + expect(tx.iat).toBe(1771934400); + }); + + it("creates with optional fields", () => { + const tx = createTransactionData({ + action: "data.purchase", + txn: { assetId: "test" }, + exp: 1771935300, + description: "Test purchase", + credentialIds: ["simpulse_id"], + }); + + expect(tx.exp).toBe(1771935300); + expect(tx.description).toBe("Test purchase"); + expect(tx.credential_ids).toEqual(["simpulse_id"]); + }); + + it("extracts action from type", () => { + const tx = createTransactionData({ + action: "data.purchase", + txn: { assetId: "test" }, + }); + expect(getAction(tx)).toBe("data.purchase"); + }); +}); + +// ============================================================================= +// Canonical JSON + Hash Tests +// ============================================================================= + +describe("Canonical JSON", () => { + it("sorts keys recursively", () => { + const tx: TransactionData = { + type: "harbour_delegate:data.purchase", + credential_ids: ["default"], + nonce: "da9b1009", + iat: 1771934400, + txn: { zzzField: "last", aaaField: "first" }, + transaction_data_hashes_alg: ["sha-256"], + }; + + const json = toCanonicalJson(tx); + + // No whitespace + expect(json).not.toContain(" "); + expect(json).not.toContain("\n"); + + // Keys sorted (aaaField before zzzField) + expect(json.indexOf("aaaField")).toBeLessThan(json.indexOf("zzzField")); + }); + + it("produces deterministic hashes", async () => { + const tx1: TransactionData = { + type: "harbour_delegate:data.purchase", + credential_ids: ["default"], + nonce: "da9b1009", + iat: 1771934400, + txn: { assetId: "test", price: "100" }, + transaction_data_hashes_alg: ["sha-256"], + }; + + const tx2: TransactionData = { ...tx1 }; + + const hash1 = await computeTransactionHash(tx1); + const hash2 = await computeTransactionHash(tx2); + + expect(hash1).toBe(hash2); + expect(hash1).toHaveLength(64); + expect(hash1).toMatch(/^[0-9a-f]{64}$/); + }); + + it("is independent of key insertion order", async () => { + const tx1: TransactionData = { + type: "harbour_delegate:data.purchase", + credential_ids: ["default"], + nonce: "da9b1009", + iat: 1771934400, + txn: { assetId: "test", price: "100" }, + transaction_data_hashes_alg: ["sha-256"], + }; + + const tx2: TransactionData = { + type: "harbour_delegate:data.purchase", + credential_ids: ["default"], + nonce: "da9b1009", + iat: 1771934400, + txn: { price: "100", assetId: "test" }, + transaction_data_hashes_alg: ["sha-256"], + }; + + expect(await computeTransactionHash(tx1)).toBe( + await computeTransactionHash(tx2) + ); + }); + + it("changes hash when data changes", async () => { + const tx1: TransactionData = { + type: "harbour_delegate:data.purchase", + credential_ids: ["default"], + nonce: "da9b1009", + iat: 1771934400, + txn: { assetId: "test", price: "100" }, + transaction_data_hashes_alg: ["sha-256"], + }; + + const tx2: TransactionData = { + ...tx1, + txn: { assetId: "test", price: "200" }, + }; + + expect(await computeTransactionHash(tx1)).not.toBe( + await computeTransactionHash(tx2) + ); + }); +}); + +// ============================================================================= +// Shared Test Vectors +// ============================================================================= + +describe("Shared canonicalization vectors", () => { + const vectorsJson = readFileSync( + resolve(FIXTURES_DIR, "canonicalization-vectors.json"), + "utf-8" + ); + const { vectors } = JSON.parse(vectorsJson); + + for (const v of vectors) { + it(`canonical JSON matches for '${v.name}'`, () => { + const td = v.input as TransactionData; + const canonical = toCanonicalJson(td); + expect(canonical).toBe(v.canonical_json); + }); + + it(`SHA-256 hash matches for '${v.name}'`, async () => { + const td = v.input as TransactionData; + const hash = await computeTransactionHash(td); + expect(hash).toBe(v.sha256_hash); + }); + + it(`challenge matches for '${v.name}'`, async () => { + const td = v.input as TransactionData; + const challenge = await createDelegationChallenge(td); + expect(challenge).toBe(v.challenge); + }); + } +}); + +// ============================================================================= +// Challenge Creation / Parsing Tests +// ============================================================================= + +describe("createDelegationChallenge", () => { + it("creates a valid challenge", async () => { + const tx: TransactionData = { + type: "harbour_delegate:data.purchase", + credential_ids: ["default"], + nonce: "da9b1009", + iat: 1771934400, + txn: { assetId: "test", price: "100" }, + transaction_data_hashes_alg: ["sha-256"], + }; + + const challenge = await createDelegationChallenge(tx); + const parts = challenge.split(" "); + + expect(parts).toHaveLength(3); + expect(parts[0]).toBe("da9b1009"); + expect(parts[1]).toBe("HARBOUR_DELEGATE"); + expect(parts[2]).toHaveLength(64); + }); +}); + +describe("parseDelegationChallenge", () => { + it("parses a valid challenge", () => { + const challenge = "da9b1009 HARBOUR_DELEGATE " + "a".repeat(64); + const result = parseDelegationChallenge(challenge); + + expect(result.nonce).toBe("da9b1009"); + expect(result.actionType).toBe("HARBOUR_DELEGATE"); + expect(result.hash).toBe("a".repeat(64)); + }); + + it("throws on invalid part count", () => { + expect(() => parseDelegationChallenge("only")).toThrow(ChallengeError); + }); + + it("throws on invalid action type", () => { + expect(() => + parseDelegationChallenge("da9b1009 WRONG_ACTION " + "a".repeat(64)) + ).toThrow(ChallengeError); + }); + + it("throws on invalid hash length", () => { + expect(() => + parseDelegationChallenge("da9b1009 HARBOUR_DELEGATE tooshort") + ).toThrow(ChallengeError); + }); + + it("throws on non-hex hash", () => { + expect(() => + parseDelegationChallenge("da9b1009 HARBOUR_DELEGATE " + "g".repeat(64)) + ).toThrow(ChallengeError); + }); + + it("round-trips with createDelegationChallenge", async () => { + const tx = createTransactionData({ + action: "data.purchase", + txn: { assetId: "test" }, + }); + + const challenge = await createDelegationChallenge(tx); + const parsed = parseDelegationChallenge(challenge); + + expect(parsed.nonce).toBe(tx.nonce); + expect(parsed.actionType).toBe(ACTION_TYPE); + expect(parsed.hash).toBe(await computeTransactionHash(tx)); + }); +}); + +// ============================================================================= +// Challenge Verification Tests +// ============================================================================= + +describe("verifyChallenge", () => { + it("verifies matching challenge", async () => { + const tx: TransactionData = { + type: "harbour_delegate:data.purchase", + credential_ids: ["default"], + nonce: "da9b1009", + iat: 1771934400, + txn: { assetId: "test" }, + transaction_data_hashes_alg: ["sha-256"], + }; + + const challenge = await createDelegationChallenge(tx); + expect(await verifyChallenge(challenge, tx)).toBe(true); + }); + + it("fails for mismatched nonce", async () => { + const tx: TransactionData = { + type: "harbour_delegate:data.purchase", + credential_ids: ["default"], + nonce: "da9b1009", + iat: 1771934400, + txn: { assetId: "test" }, + transaction_data_hashes_alg: ["sha-256"], + }; + + const hash = await computeTransactionHash(tx); + const challenge = `different HARBOUR_DELEGATE ${hash}`; + expect(await verifyChallenge(challenge, tx)).toBe(false); + }); + + it("fails for mismatched hash", async () => { + const tx: TransactionData = { + type: "harbour_delegate:data.purchase", + credential_ids: ["default"], + nonce: "da9b1009", + iat: 1771934400, + txn: { assetId: "test" }, + transaction_data_hashes_alg: ["sha-256"], + }; + + const challenge = "da9b1009 HARBOUR_DELEGATE " + "b".repeat(64); + expect(await verifyChallenge(challenge, tx)).toBe(false); + }); +}); + +// ============================================================================= +// Validation Tests +// ============================================================================= + +describe("validateTransactionData", () => { + it("validates a valid transaction", () => { + const tx = createTransactionData({ + action: "data.purchase", + txn: { assetId: "test" }, + }); + expect(() => validateTransactionData(tx)).not.toThrow(); + }); + + it("throws for invalid type prefix", () => { + const tx: TransactionData = { + type: "wrong_prefix:data.purchase", + credential_ids: ["default"], + nonce: "da9b1009", + iat: Math.floor(Date.now() / 1000), + txn: { assetId: "test" }, + transaction_data_hashes_alg: ["sha-256"], + }; + expect(() => validateTransactionData(tx)).toThrow(ChallengeError); + }); + + it("throws for short nonce", () => { + const tx: TransactionData = { + type: "harbour_delegate:data.purchase", + credential_ids: ["default"], + nonce: "abc", + iat: Math.floor(Date.now() / 1000), + txn: { assetId: "test" }, + transaction_data_hashes_alg: ["sha-256"], + }; + expect(() => validateTransactionData(tx)).toThrow(/Nonce too short/); + }); + + it("throws for old timestamp", () => { + const tx: TransactionData = { + type: "harbour_delegate:data.purchase", + credential_ids: ["default"], + nonce: "da9b1009", + iat: Math.floor(Date.now() / 1000) - 600, + txn: { assetId: "test" }, + transaction_data_hashes_alg: ["sha-256"], + }; + expect(() => validateTransactionData(tx, { maxAgeSeconds: 300 })).toThrow( + /too old/ + ); + }); + + it("throws for future timestamp", () => { + const tx: TransactionData = { + type: "harbour_delegate:data.purchase", + credential_ids: ["default"], + nonce: "da9b1009", + iat: Math.floor(Date.now() / 1000) + 300, + txn: { assetId: "test" }, + transaction_data_hashes_alg: ["sha-256"], + }; + expect(() => validateTransactionData(tx)).toThrow(/future/); + }); + + it("throws for expired transaction", () => { + const tx = createTransactionData({ + action: "data.purchase", + txn: { assetId: "test" }, + exp: Math.floor(Date.now() / 1000) - 300, + }); + expect(() => validateTransactionData(tx)).toThrow(/expired/); + }); +}); + +// ============================================================================= +// Display Tests +// ============================================================================= + +describe("renderTransactionDisplay", () => { + it("renders basic display", () => { + const tx: TransactionData = { + type: "harbour_delegate:data.purchase", + credential_ids: ["default"], + nonce: "da9b1009", + iat: 1771934400, + txn: { assetId: "urn:uuid:test", price: "100", currency: "ENVITED" }, + transaction_data_hashes_alg: ["sha-256"], + }; + + const display = renderTransactionDisplay(tx); + + expect(display).toContain("requests your authorization"); + expect(display).toContain("Purchase data asset"); + expect(display).toContain("da9b1009"); + }); + + it("renders all known action labels", () => { + for (const [action, label] of Object.entries(ACTION_LABELS)) { + const tx = createTransactionData({ + action, + txn: { testField: "value" }, + }); + const display = renderTransactionDisplay(tx); + expect(display).toContain(label); + } + }); +}); diff --git a/tests/typescript/harbour/sd-jwt-vp.test.ts b/tests/typescript/harbour/sd-jwt-vp.test.ts new file mode 100644 index 0000000..6db76e7 --- /dev/null +++ b/tests/typescript/harbour/sd-jwt-vp.test.ts @@ -0,0 +1,272 @@ +/** + * Tests for SD-JWT VP (Verifiable Presentations with selective disclosure). + */ + +import { describe, expect, it, beforeAll } from "vitest"; + +import { + generateP256Keypair, + p256PublicKeyToDidKey, +} from "../../../src/typescript/harbour/keys.js"; +import { issueSdJwtVc } from "../../../src/typescript/harbour/sd-jwt.js"; +import { + issueSdJwtVp, + verifySdJwtVp, +} from "../../../src/typescript/harbour/sd-jwt-vp.js"; +import { VerificationError } from "../../../src/typescript/harbour/verifier.js"; + +// Shared test keys +let issuerPrivate: CryptoKey; +let issuerPublic: CryptoKey; +let holderPrivate: CryptoKey; +let holderPublic: CryptoKey; +let holderDid: string; +let sampleSdJwtVc: string; + +beforeAll(async () => { + const issuerKp = await generateP256Keypair(); + issuerPrivate = issuerKp.privateKey; + issuerPublic = issuerKp.publicKey; + + const holderKp = await generateP256Keypair(); + holderPrivate = holderKp.privateKey; + holderPublic = holderKp.publicKey; + holderDid = await p256PublicKeyToDidKey(holderKp.publicKey); + + // SD-JWT-VC uses flat claims + const claims = { + iss: "did:web:issuer.example.com", + sub: holderDid, + givenName: "Alice", + familyName: "Smith", + email: "alice@example.com", + memberOf: "Example Organization", + role: "member", + }; + + sampleSdJwtVc = await issueSdJwtVc(claims, issuerPrivate, { + vct: "https://example.com/MembershipCredential", + disclosable: ["givenName", "familyName", "email"], + }); +}); + +// ============================================================================= +// Issue Tests +// ============================================================================= + +describe("issueSdJwtVp", () => { + it("issues a basic VP with all disclosures", async () => { + const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { + nonce: "test-nonce-123", + audience: "did:web:verifier.example.com", + }); + + expect(vp).toContain("~"); + const parts = vp.split("~"); + // vp-jwt + issuer-jwt + 3 disclosures + kb-jwt = 6 + expect(parts.length).toBeGreaterThanOrEqual(4); + + // Check VP JWT header + const vpHeader = JSON.parse( + Buffer.from(parts[0].split(".")[0], "base64url").toString() + ); + expect(vpHeader.typ).toBe("vp+sd-jwt"); + expect(vpHeader.alg).toBe("ES256"); + + // Check KB-JWT header + const kbHeader = JSON.parse( + Buffer.from(parts[parts.length - 1].split(".")[0], "base64url").toString() + ); + expect(kbHeader.typ).toBe("kb+jwt"); + }); + + it("issues with no disclosures (max privacy)", async () => { + const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { + disclosures: [], + nonce: "nonce-789", + }); + + const parts = vp.split("~"); + // vp-jwt + issuer-jwt + kb-jwt = 3 (no disclosures) + expect(parts.length).toBeGreaterThanOrEqual(3); + }); + + it("issues with evidence", async () => { + const evidence = [ + { + type: "DelegatedSignatureEvidence", + transactionData: { + type: "harbour_delegate:data.purchase", + credential_ids: ["simpulse_id"], + nonce: "tx-nonce", + iat: 1771934400, + txn: { assetId: "tx:abc123", price: "100" }, + }, + delegatedTo: "did:web:signing-service.example.com", + }, + ]; + + const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { + evidence, + nonce: "tx-consent-nonce", + audience: "did:web:signing-service.example.com", + }); + + // Parse VP payload to check evidence + const parts = vp.split("~"); + const vpPayload = JSON.parse( + Buffer.from(parts[0].split(".")[1], "base64url").toString() + ); + + expect(vpPayload.vp.evidence).toHaveLength(1); + expect(vpPayload.vp.evidence[0].type).toBe( + "DelegatedSignatureEvidence" + ); + }); + + it("issues with holder DID", async () => { + const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { + holderDid, + nonce: "holder-nonce", + }); + + const parts = vp.split("~"); + const vpPayload = JSON.parse( + Buffer.from(parts[0].split(".")[1], "base64url").toString() + ); + + expect(vpPayload.iss).toBe(holderDid); + expect(vpPayload.vp.holder).toBe(holderDid); + }); +}); + +// ============================================================================= +// Verify Tests +// ============================================================================= + +describe("verifySdJwtVp", () => { + it("verifies a basic VP", async () => { + const nonce = "verify-test-nonce"; + const audience = "did:web:verifier.example.com"; + + const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { + nonce, + audience, + }); + + const result = await verifySdJwtVp(vp, issuerPublic, holderPublic, { + expectedNonce: nonce, + expectedAudience: audience, + }); + + expect(result.credential).toBeDefined(); + expect(result.nonce).toBe(nonce); + expect(result.audience).toBe(audience); + }); + + it("returns disclosed claims", async () => { + const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate); + const result = await verifySdJwtVp(vp, issuerPublic, holderPublic); + + // Non-SD claims + expect(result.credential.memberOf).toBe("Example Organization"); + expect(result.credential.role).toBe("member"); + // SD claims (all disclosed) + expect(result.credential.givenName).toBe("Alice"); + expect(result.credential.familyName).toBe("Smith"); + expect(result.credential.email).toBe("alice@example.com"); + }); + + it("respects selective disclosure", async () => { + const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { + disclosures: ["givenName"], + }); + + const result = await verifySdJwtVp(vp, issuerPublic, holderPublic); + + expect(result.credential.givenName).toBe("Alice"); + expect(result.credential.familyName).toBeUndefined(); + expect(result.credential.email).toBeUndefined(); + }); + + it("returns evidence", async () => { + const evidence = [ + { + type: "DelegatedSignatureEvidence", + transactionData: { + type: "harbour_delegate:blockchain.approve", + credential_ids: ["default"], + nonce: "consent-nonce", + iat: 1771934400, + txn: { contract: "0x1234" }, + }, + }, + ]; + + const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { evidence }); + const result = await verifySdJwtVp(vp, issuerPublic, holderPublic); + + expect(result.evidence).toHaveLength(1); + expect(result.evidence![0].type).toBe("DelegatedSignatureEvidence"); + }); + + it("fails with wrong issuer key", async () => { + const wrongKp = await generateP256Keypair(); + const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate); + + await expect( + verifySdJwtVp(vp, wrongKp.publicKey, holderPublic) + ).rejects.toThrow(VerificationError); + }); + + it("fails with wrong holder key", async () => { + const wrongKp = await generateP256Keypair(); + const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate); + + await expect( + verifySdJwtVp(vp, issuerPublic, wrongKp.publicKey) + ).rejects.toThrow(VerificationError); + }); + + it("fails with nonce mismatch", async () => { + const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { + nonce: "original-nonce", + }); + + await expect( + verifySdJwtVp(vp, issuerPublic, holderPublic, { + expectedNonce: "wrong-nonce", + }) + ).rejects.toThrow(/Nonce mismatch/); + }); + + it("fails with audience mismatch", async () => { + const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { + audience: "did:web:expected.example.com", + }); + + await expect( + verifySdJwtVp(vp, issuerPublic, holderPublic, { + expectedAudience: "did:web:wrong.example.com", + }) + ).rejects.toThrow(/Audience mismatch/); + }); +}); + +// ============================================================================= +// Edge Cases +// ============================================================================= + +describe("Edge cases", () => { + it("rejects invalid SD-JWT-VC format", async () => { + await expect( + issueSdJwtVp("not-a-valid-sd-jwt", holderPrivate) + ).rejects.toThrow("Invalid SD-JWT-VC format"); + }); + + it("rejects invalid SD-JWT VP format", async () => { + await expect( + verifySdJwtVp("not~valid", issuerPublic, holderPublic) + ).rejects.toThrow("Invalid SD-JWT VP format"); + }); +}); From f2df582a63ced6ab632e4d811efaaa475b28cead Mon Sep 17 00:00:00 2001 From: jdsika Date: Wed, 25 Feb 2026 12:36:01 +0100 Subject: [PATCH 03/78] feat(harbour): align delegated signing and did:webs credential modeling - enforce delegation challenge derivation and verification from transaction_data (` HARBOUR_DELEGATE `) in Python and TypeScript - enforce KB-JWT transaction_data hash and audience bindings in SD-JWT VP flows - add and align cross-runtime canonicalization vectors/tests for JSON, hashes, challenges, and OID4VP transaction_data parameter/hash handling - standardize delegated signing examples/docs on OID4VP snake_case fields (`transaction_data`, `credential_ids`, `transaction_data_hashes*`, `asset_id`) - migrate natural/legal person examples to did:webs identifiers and add did-webs DID document examples - model EmailPass as credential evidence issued by a did:webs issuer DID - extend SHACL validation pipeline with ontology loading probe and required ontology checks (`cs`, `cred`, `core`, `harbour`, `gx`) via ontology-management-base validation suite Refs: #2 Signed-off-by: jdsika --- Makefile | 34 +- README.md | 12 +- docs/guide/delegated-signing.md | 39 ++- docs/guide/evidence.md | 18 +- docs/specs/delegation-challenge-encoding.md | 66 ++-- examples/consent-vp.json | 32 +- examples/delegated-signing-receipt.json | 38 ++- examples/did-webs/README.md | 34 ++ .../did-webs/legal-person-altme_sas.did.json | 27 ++ .../did-webs/legal-person-bmw_ag.did.json | 27 ++ ...e8400-e29b-41d4-a716-446655440000.did.json | 27 ++ examples/legal-person-credential.json | 6 +- examples/natural-person-credential.json | 10 +- linkml/harbour.yaml | 8 +- src/python/harbour/delegation.py | 28 +- src/python/harbour/sd_jwt_vp.py | 231 ++++++++++++-- src/typescript/harbour/delegation.ts | 34 ++ src/typescript/harbour/kb-jwt.ts | 18 +- src/typescript/harbour/sd-jwt-vp.ts | 300 ++++++++++++++++-- tests/fixtures/canonicalization-vectors.json | 98 ++++-- tests/fixtures/sample-vc.json | 2 +- tests/interop/test_cross_runtime.py | 59 ++++ .../python/credentials/test_claim_mapping.py | 5 +- .../python/credentials/test_example_signer.py | 10 +- tests/python/harbour/test_delegation.py | 112 ++++--- tests/python/harbour/test_sd_jwt_vp.py | 208 ++++++++++-- tests/typescript/harbour/delegation.test.ts | 60 ++-- tests/typescript/harbour/kb-jwt.test.ts | 8 +- tests/typescript/harbour/sd-jwt-vp.test.ts | 164 +++++++++- .../ontology-loading-probe.json | 12 + 30 files changed, 1458 insertions(+), 269 deletions(-) create mode 100644 examples/did-webs/README.md create mode 100644 examples/did-webs/legal-person-altme_sas.did.json create mode 100644 examples/did-webs/legal-person-bmw_ag.did.json create mode 100644 examples/did-webs/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json create mode 100644 tests/validation-probe/ontology-loading-probe.json diff --git a/Makefile b/Makefile index eecd2f8..e2e52e3 100644 --- a/Makefile +++ b/Makefile @@ -86,7 +86,7 @@ help: @echo "Testing:" @echo " make test - Run Python pytest suite" @echo " make test-ts - Run TypeScript vitest suite" - @echo " make test-all - Run all tests (Python + TypeScript)" + @echo " make test-all - Run Python tests + SHACL conformance + TypeScript tests" @echo " make test-cov - Run Python tests with coverage report" @echo "" @echo "TypeScript:" @@ -192,10 +192,31 @@ validate: validate-shacl: $(call check_dev_setup) @echo "🔧 Running SHACL data conformance check on examples..." - @cd $(OMB_SUBMODULE_DIR) && $(shell which $(PYTHON)) -m src.tools.validators.validation_suite \ - --run check-data-conformance \ - --data-paths ../../examples/ \ - --artifacts ../../artifacts ./artifacts + @cd $(OMB_SUBMODULE_DIR) && \ + tmp_output=$$(mktemp) && \ + $(PYTHON) -m src.tools.validators.validation_suite \ + --run check-data-conformance \ + --data-paths ../../examples/ ../../tests/validation-probe/ontology-loading-probe.json \ + --artifacts ../../artifacts > $$tmp_output 2>&1 ; \ + status=$$? ; \ + cat $$tmp_output ; \ + if [ $$status -ne 0 ]; then \ + rm -f $$tmp_output ; \ + exit $$status ; \ + fi ; \ + for required in \ + "imports/cs/cs.owl.ttl" \ + "imports/cred/cred.owl.ttl" \ + "../../artifacts/core/core.owl.ttl" \ + "../../artifacts/harbour/harbour.owl.ttl" \ + "artifacts/gx/gx.owl.ttl" ; do \ + if ! grep -q "$$required" $$tmp_output ; then \ + echo "❌ Required ontology not loaded by validation suite: $$required" >&2 ; \ + rm -f $$tmp_output ; \ + exit 1 ; \ + fi ; \ + done ; \ + rm -f $$tmp_output @echo "✅ SHACL validation complete" # Run pre-commit hooks on all files @@ -252,8 +273,9 @@ all: # Run all tests (Python + TypeScript) test-all: - @echo "🔧 Running all tests (Python + TypeScript)..." + @echo "🔧 Running all tests (Python + SHACL + TypeScript)..." @$(MAKE) --no-print-directory test + @$(MAKE) --no-print-directory validate-shacl @$(MAKE) --no-print-directory test-ts @echo "✅ All tests complete" diff --git a/README.md b/README.md index fcf3926..f079673 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ JOSE signing and verification library for W3C Verifiable Credentials, supporting - **ES256 (P-256)**: EUDI HAIP compliant algorithm - **EdDSA (Ed25519)**: Supported (deprecated per RFC 9864, use ES256 for production) - **X.509 Support**: Certificate chains via `x5c` header -- **DID Support**: `did:key` and `did:web` resolution +- **DID Support**: `did:key` key identifiers plus `did:web` / `did:webs` subject identifiers (resolution handled by integrators) - **Selective Disclosure**: Native SD-JWT-VC with disclosable claims - **Key Binding**: KB-JWT for holder binding in presentations - **Harbour Credential Types**: Base credential framework with composition slots for Gaia-X compliance @@ -127,7 +127,7 @@ The composition pattern keeps harbour properties on the harbour-typed outer node "issuer": "did:web:trust-anchor.example.com", "validFrom": "2024-01-15T00:00:00Z", "credentialSubject": { - "id": "did:web:participant.example.com", + "id": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", "type": "harbour:LegalPerson", "name": "Example Corporation GmbH", "gxParticipant": { @@ -174,8 +174,11 @@ make validate ### Run Tests ```bash -# Run all fixture validations via pytest +# Run Python tests only make test + +# Run full pipeline (Python + SHACL conformance via validation_suite.py + TypeScript) +make test-all ``` ## CLI Usage @@ -232,7 +235,8 @@ submodules/ examples/ ├── legal-person-credential.json # Harbour credential examples ├── natural-person-credential.json # (canonical unsigned JSON-LD) -└── service-offering-credential.json +├── service-offering-credential.json +└── did-webs/ # Example did:webs DID documents used by examples tests/ ├── fixtures/ # Shared test fixtures diff --git a/docs/guide/delegated-signing.md b/docs/guide/delegated-signing.md index 9035289..7ccec9d 100644 --- a/docs/guide/delegated-signing.md +++ b/docs/guide/delegated-signing.md @@ -96,6 +96,23 @@ The user's DID document (`did:web:carlo.simpulse.io`) must contain a verificatio } ``` +### Repository Boundary (did:web / did:webs) + +This repository verifies signatures and hash bindings, but it does **not** host or publish DID documents. + +- Integrators must publish DID documents at the correct HTTPS location for the chosen method (`did:web` or `did:webs`). +- Integrators must run DID resolution and pass the resolved holder key into `verify_sd_jwt_vp(...)`. +- Repository examples now use `did:webs` identifiers for person subjects. See `examples/did-webs/` for static example DID documents used by `examples/*.json`. +- Naming policy in examples: + - Natural persons use UUID-based path segments (no real names in DID path). + - Legal persons may use organization suffixes (for example `bmw_ag`). + +Current integration hooks and TODOs: + +- `issue_sd_jwt_vp(..., holder_did=...)` allows the wallet DID to be embedded in the consent VP. +- `verify_sd_jwt_vp(..., holder_public_key=...)` accepts the DID-resolved public key from your resolver stack. +- TODO: Add optional resolver callback adapters for `did:web`/`did:webs` so verification can resolve keys in-process. + ## OID4VP Transaction Data The signing service creates an OID4VP-aligned transaction data object (see [Delegation Challenge Encoding](../specs/delegation-challenge-encoding.md)): @@ -108,7 +125,7 @@ The signing service creates an OID4VP-aligned transaction data object (see [Dele "nonce": "da9b1009", "iat": 1771934400, "txn": { - "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", "marketplace": "did:web:dataspace.envited.io" @@ -116,6 +133,10 @@ The signing service creates an OID4VP-aligned transaction data object (see [Dele } ``` +Naming note: +- `transaction_data` and `credential_ids` are OID4VP-defined snake_case fields. +- `txn` is profile-defined payload; Harbour v1 standardizes snake_case keys such as `asset_id`. + ## Creating the Consent VP When the signing service requests consent, the user creates an **SD-JWT VP** with: @@ -136,13 +157,13 @@ sd_jwt_vc = "eyJ...~disclosure1~disclosure2~..." # Transaction evidence (OID4VP-aligned) evidence = [{ "type": "DelegatedSignatureEvidence", - "transactionData": { + "transaction_data": { "type": "harbour_delegate:data.purchase", "credential_ids": ["simpulse_id"], "nonce": "da9b1009", "iat": 1771934400, "txn": { - "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED" } @@ -170,13 +191,13 @@ const sdJwtVp = await issueSdJwtVp(sdJwtVc, holderPrivateKey, { disclosures: ['memberOf'], evidence: [{ type: 'DelegatedSignatureEvidence', - transactionData: { + transaction_data: { type: 'harbour_delegate:data.purchase', credential_ids: ['simpulse_id'], nonce: 'da9b1009', iat: 1771934400, txn: { - assetId: 'urn:uuid:550e8400-e29b-41d4-a716-446655440000', + asset_id: 'urn:uuid:550e8400-e29b-41d4-a716-446655440000', price: '100', currency: 'ENVITED' } @@ -188,6 +209,8 @@ const sdJwtVp = await issueSdJwtVp(sdJwtVc, holderPrivateKey, { }); ``` +`issue_sd_jwt_vp` / `issueSdJwtVp` derives the delegation challenge (` HARBOUR_DELEGATE `) and writes it to `evidence[].challenge`. It also computes the OID4VP `transaction_data_hashes` value (base64url(SHA-256(transaction_data request string))) and binds/verifies that in KB-JWT on `verify_sd_jwt_vp` / `verifySdJwtVp`. + ## Verification The signing service verifies the VP before executing the transaction: @@ -204,9 +227,9 @@ result = verify_sd_jwt_vp( ) # Check transaction data matches original request -tx = result["evidence"][0]["transactionData"] +tx = result["evidence"][0]["transaction_data"] assert tx["type"] == "harbour_delegate:data.purchase" -assert tx["txn"]["assetId"] == "urn:uuid:550e8400-e29b-41d4-a716-446655440000" +assert tx["txn"]["asset_id"] == "urn:uuid:550e8400-e29b-41d4-a716-446655440000" # Check credential is still valid (CRSet) # ... revocation check ... @@ -226,7 +249,7 @@ After executing the transaction, the signing service issues a **receipt credenti "type": "harbour:DelegatedSignatureEvidence", "verifiablePresentation": "", "delegatedTo": "did:web:signing-service.envited.io", - "transactionData": { "..." } + "transaction_data": { "..." } }], "credentialStatus": [{ "type": "harbour:CRSetEntry", diff --git a/docs/guide/evidence.md b/docs/guide/evidence.md index a210532..ef4705a 100644 --- a/docs/guide/evidence.md +++ b/docs/guide/evidence.md @@ -18,6 +18,7 @@ Evidence creates an **audit trail** — allowing third parties to verify not jus Proves that the issuer verified claims using a prior credential or verifiable presentation. The embedded VP contains the credentials the issuer relied upon (e.g., email verification, notary attestation). **Use case (email verification)**: A `NaturalPersonCredential` includes evidence that the user's email was verified via an email verification service (e.g., Altme EmailPass). +The EmailPass proof is modeled as a VC issued by a `did:webs` issuer DID. ```json { @@ -25,11 +26,11 @@ Proves that the issuer verified claims using a prior credential or verifiable pr "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "holder": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", "verifiableCredential": [ { "type": ["VerifiableCredential"], - "issuer": "did:web:altme.io", + "issuer": "did:webs:issuers.altme.example:legal-persons:altme_sas:EMtR9m3wZ5xV2k8sP4jQ7nH1cD6bL0fYgAaUu2hCqK9M", "credentialSubject": { "type": "EmailPass", "email": "alice@example.com" @@ -48,7 +49,7 @@ Proves that the issuer verified claims using a prior credential or verifiable pr "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:web:participant.example.com", + "holder": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", "verifiableCredential": [ { "type": ["VerifiableCredential"], @@ -77,19 +78,20 @@ Evidence on a **receipt credential** (SD-JWT-VC) that a signing service executed "type": "harbour:DelegatedSignatureEvidence", "verifiablePresentation": "", "delegatedTo": "did:web:signing-service.envited.io", - "transactionData": { + "transaction_data": { "type": "harbour_delegate:data.purchase", "credential_ids": ["simpulse_id"], "transaction_data_hashes_alg": ["sha-256"], "nonce": "da9b1009", "iat": 1771934400, "txn": { - "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", "marketplace": "did:web:dataspace.envited.io" } - } + }, + "challenge": "da9b1009 HARBOUR_DELEGATE c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f" } ``` @@ -209,13 +211,13 @@ DelegatedSignatureEvidence: slots: - verifiablePresentation - delegatedTo - - transactionData + - transaction_data slot_usage: verifiablePresentation: required: true delegatedTo: required: true - transactionData: + transaction_data: required: true ``` diff --git a/docs/specs/delegation-challenge-encoding.md b/docs/specs/delegation-challenge-encoding.md index fc5c844..18c05e0 100644 --- a/docs/specs/delegation-challenge-encoding.md +++ b/docs/specs/delegation-challenge-encoding.md @@ -22,7 +22,7 @@ Following the OID4VP pattern: | Component | Purpose | Location | |-----------|---------|----------| | **Full transaction data** | Human review, business logic | Request body OR external reference | -| **Transaction data hash** | Cryptographic binding | `proof.challenge` (signed by holder) | +| **Transaction data binding** | Cryptographic integrity | `proof.challenge` (Harbour challenge hash) + KB-JWT `transaction_data_hashes` (OID4VP hash) | | **Verifier identity** | Trust anchor | `proof.domain` | | **Replay protection** | Freshness | `proof.nonce` / timestamp in challenge | @@ -48,7 +48,7 @@ Where: ### 2.2 Example ``` -da9b1009 HARBOUR_DELEGATE d0450062b3c4c9168ac8266f0806d62f5d95ed96894d5a9a0aaddf4298317eaa +da9b1009 HARBOUR_DELEGATE c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f ``` This format is inspired by [simpulse-id-credentials](https://github.com/ASCS-eV/simpulse-id-credentials) which uses: @@ -127,9 +127,21 @@ This structure aligns with [OID4VP §5.1 `transaction_data`](https://openid.net/ |-------------|--------------| | `harbour_delegate:blockchain.transfer` | `chain`, `contract`, `recipient`, `amount`, `token` | | `harbour_delegate:blockchain.execute` | `chain`, `contract`, `method`, `params`, `value` | -| `harbour_delegate:data.purchase` | `assetId`, `price`, `currency`, `marketplace` | -| `harbour_delegate:contract.sign` | `documentHash`, `documentUri`, `parties` | -| `harbour_delegate:credential.issue` | `credentialType`, `subject`, `claims` | +| `harbour_delegate:data.purchase` | `asset_id`, `price`, `currency`, `marketplace` | +| `harbour_delegate:contract.sign` | `document_hash`, `document_uri`, `parties` | +| `harbour_delegate:credential.issue` | `credential_type`, `subject`, `claims` | + +#### Naming Conventions and Compatibility Boundary + +Different standards in this flow use different naming conventions by design: + +| Layer | Source | Naming Rule | +|-------|--------|-------------| +| VC envelope/evidence terms | W3C VC Data Model | Use VC-defined terms as-is (`credentialStatus`, `validFrom`, `evidence`, etc.) | +| OID4VP protocol fields | OpenID4VP / OAuth parameters and KB-JWT profile claims | Use snake_case exactly (`transaction_data`, `credential_ids`, `transaction_data_hashes`, `transaction_data_hashes_alg`) | +| Harbour action payload (`txn`) | Harbour transaction type profile | Profile-defined keys; Harbour v1 uses snake_case action keys (for example `asset_id`) | + +Important: `txn` keys are part of canonicalization and hashing. Renaming a key (for example `asset_id` to `assetId`) changes the canonical JSON and therefore changes the challenge/hash binding. ### 3.5 Example Transaction Data @@ -143,7 +155,7 @@ This structure aligns with [OID4VP §5.1 `transaction_data`](https://openid.net/ "exp": 1771935300, "description": "Purchase sensor data package from BMW", "txn": { - "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", "marketplace": "did:web:dataspace.envited.io" @@ -168,7 +180,7 @@ def compute_transaction_hash(transaction_data: dict) -> str: The resulting challenge: ``` -da9b1009 HARBOUR_DELEGATE d0450062b3c4c9168ac8266f0806d62f5d95ed96894d5a9a0aaddf4298317eaa +da9b1009 HARBOUR_DELEGATE c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f ``` --- @@ -193,7 +205,7 @@ The delegated consent is captured as `evidence` in a Verifiable Credential or di "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH", + "holder": "did:web:user.example.com", "verifiableCredential": [ "" ], @@ -201,9 +213,9 @@ The delegated consent is captured as `evidence` in a Verifiable Credential or di "type": "DataIntegrityProof", "cryptosuite": "ecdsa-rdfc-2019", "proofPurpose": "authentication", - "challenge": "da9b1009 HARBOUR_DELEGATE d0450062b3c4c9168ac8266f0806d62f5d95ed96894d5a9a0aaddf4298317eaa", + "challenge": "da9b1009 HARBOUR_DELEGATE c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f", "domain": "did:web:harbour.signing-service.example.com", - "verificationMethod": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH#z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH", + "verificationMethod": "did:web:user.example.com#key-1", "created": "2026-02-24T12:00:05Z", "proofValue": "z5vgFc..." } @@ -226,7 +238,7 @@ The delegated consent is captured as `evidence` in a Verifiable Credential or di The full transaction data object (§3) can be stored in one of: -1. **VP `evidence[].transactionData`** — Inline (increases VP size) +1. **VP `evidence[].transaction_data`** — Inline (increases VP size) 2. **External reference** — VP contains hash, full data at `ref` URL 3. **Request context** — OID4VP `transaction_data` parameter (recommended) @@ -275,7 +287,7 @@ This specification is designed for seamless integration with [OpenID for Verifia "nonce": "da9b1009", "iat": 1771934400, "txn": { - "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", "marketplace": "did:web:dataspace.envited.io" @@ -293,19 +305,19 @@ Per OID4VP Appendix B.3.3, the KB-JWT includes: "aud": "did:web:harbour.signing-service.example.com", "iat": 1709838604, "sd_hash": "Dy-RYwZfaaoC3inJbLslgPvMp09bH-clYP_3qbRqtW4", - "transaction_data_hashes": ["d0450062b3c4c9168ac8266f0806d62f5d95ed96894d5a9a0aaddf4298317eaa"], + "transaction_data_hashes": ["7W0LFUTpMvb6nJK7ngamNNY0zNvxqJ-2jNXTmLzhWQE"], "transaction_data_hashes_alg": "sha-256" } ``` ### 5.4 Dual Support -Our challenge format supports both: +Our challenge profile and OID4VP binding support both: -1. **OID4VP flow** — Hash in `transaction_data_hashes` (KB-JWT claim) -2. **Direct VP flow** — Hash in `proof.challenge` (W3C proof) +1. **OID4VP flow** — Hash in `transaction_data_hashes` (KB-JWT claim; hash over `transaction_data` request string) +2. **Direct VP flow** — Hash in `proof.challenge` (W3C proof; hash over canonical decoded object) -The same hash can appear in both locations for maximum compatibility. +These are two related but distinct representations and MUST be verified according to their respective rules. --- @@ -364,7 +376,7 @@ from harbour.delegation import TransactionData, create_delegation_challenge, ver tx = TransactionData.create( action="data.purchase", txn={ - "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", "marketplace": "did:web:dataspace.envited.io", @@ -393,7 +405,7 @@ import { const tx = createTransactionData({ action: 'data.purchase', txn: { - assetId: 'urn:uuid:550e8400-e29b-41d4-a716-446655440000', + asset_id: 'urn:uuid:550e8400-e29b-41d4-a716-446655440000', price: '100', currency: 'ENVITED', marketplace: 'did:web:dataspace.envited.io', @@ -463,7 +475,7 @@ from harbour.delegation import TransactionData, render_transaction_display tx = TransactionData.create( action="data.purchase", - txn={"assetId": "urn:uuid:550e8400...", "price": "100", "currency": "ENVITED"}, + txn={"asset_id": "urn:uuid:550e8400...", "price": "100", "currency": "ENVITED"}, ) print(render_transaction_display(tx)) ``` @@ -475,7 +487,7 @@ import { createTransactionData, renderTransactionDisplay } from '@reachhaven/har const tx = createTransactionData({ action: 'data.purchase', - txn: { assetId: 'urn:uuid:550e8400...', price: '100', currency: 'ENVITED' }, + txn: { asset_id: 'urn:uuid:550e8400...', price: '100', currency: 'ENVITED' }, }); console.log(renderTransactionDisplay(tx)); ``` @@ -497,7 +509,7 @@ These examples use the shared test vectors from `tests/fixtures/canonicalization "nonce": "da9b1009", "iat": 1771934400, "txn": { - "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", "marketplace": "did:web:dataspace.envited.io" @@ -507,7 +519,7 @@ These examples use the shared test vectors from `tests/fixtures/canonicalization **Challenge:** ``` -da9b1009 HARBOUR_DELEGATE 86a3e927a80d9e858a71b37f574aa65cab184c5fc65e8c7824771f84ad6ed97e +da9b1009 HARBOUR_DELEGATE c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f ``` ### 10.2 Blockchain Transfer Transaction @@ -547,7 +559,7 @@ ef567890 HARBOUR_DELEGATE 0736db89c15be412294f96717a3e435f89d095e7e953b1808c4222 "exp": 1771935300, "description": "Sign partnership agreement", "txn": { - "documentHash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "document_hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "parties": ["did:web:alice.example", "did:web:bob.example"] } } @@ -555,7 +567,7 @@ ef567890 HARBOUR_DELEGATE 0736db89c15be412294f96717a3e435f89d095e7e953b1808c4222 **Challenge:** ``` -ab12cd34 HARBOUR_DELEGATE daccac20a99de56e2a5d108fcfa53d0d03faa7d4cd29552ae1dbdc486120d3ec +ab12cd34 HARBOUR_DELEGATE 0863ac13bc5f15c7dfcdee71b8beea1aead4b822d0a7c03154405da4f192af08 ``` --- @@ -588,7 +600,7 @@ This specification aligns with [OID4VP Transaction Data (§8.4)](https://openid. | `transaction_data` request param | Transaction Data Object (§3) | | `transaction_data.type` | `"harbour_delegate:"` | | `transaction_data.txn` | Action-specific transaction details | -| `transaction_data_hashes` in KB-JWT | Same hash as in `proof.challenge` | +| `transaction_data_hashes` in KB-JWT | OID4VP hash over transaction_data request string | | `transaction_data_hashes_alg` | `"sha-256"` | ### Integration Example @@ -606,7 +618,7 @@ OID4VP authorization request: "nonce": "da9b1009", "iat": 1771934400, "txn": { - "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", "marketplace": "did:web:dataspace.envited.io" diff --git a/examples/consent-vp.json b/examples/consent-vp.json index 8b6f3bd..40dc445 100644 --- a/examples/consent-vp.json +++ b/examples/consent-vp.json @@ -1,40 +1,52 @@ { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiablePresentation"], - "holder": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "@context": [ + "https://www.w3.org/ns/credentials/v2" + ], + "type": [ + "VerifiablePresentation" + ], + "holder": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", "verifiableCredential": [ { "@context": [ "https://www.w3.org/ns/credentials/v2", "https://w3id.org/reachhaven/harbour/credentials/v1/" ], - "type": ["VerifiableCredential", "harbour:NaturalPersonCredential"], + "type": [ + "VerifiableCredential", + "harbour:NaturalPersonCredential" + ], "issuer": "did:web:issuer.example.com", "validFrom": "2024-01-15T00:00:00Z", "credentialSubject": { - "id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "id": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", "type": "harbour:NaturalPerson", "name": "Alice Smith", - "memberOf": "did:web:participant.example.com" + "memberOf": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" } } ], "evidence": [ { "type": "harbour:DelegatedSignatureEvidence", - "transactionData": { + "transaction_data": { "type": "harbour_delegate:data.purchase", - "credential_ids": ["simpulse_id"], - "transaction_data_hashes_alg": ["sha-256"], + "credential_ids": [ + "simpulse_id" + ], + "transaction_data_hashes_alg": [ + "sha-256" + ], "nonce": "da9b1009", "iat": 1771934400, "txn": { - "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", "marketplace": "did:web:dataspace.envited.io" } }, + "challenge": "da9b1009 HARBOUR_DELEGATE c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f", "delegatedTo": "did:web:signing-service.envited.io" } ] diff --git a/examples/delegated-signing-receipt.json b/examples/delegated-signing-receipt.json index cd82316..5fceaec 100644 --- a/examples/delegated-signing-receipt.json +++ b/examples/delegated-signing-receipt.json @@ -11,9 +11,9 @@ "issuer": "did:web:signing-service.envited.io", "validFrom": "2025-06-25T10:00:00Z", "credentialSubject": { - "id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "id": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", "type": "harbour:TransactionReceipt", - "transactionHash": "86a3e927a80d9e858a71b37f574aa65cab184c5fc65e8c7824771f84ad6ed97e", + "transactionHash": "c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f", "blockchainTxId": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" }, "credentialStatus": [ @@ -27,39 +27,51 @@ { "type": "harbour:DelegatedSignatureEvidence", "verifiablePresentation": { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiablePresentation"], - "holder": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "@context": [ + "https://www.w3.org/ns/credentials/v2" + ], + "type": [ + "VerifiablePresentation" + ], + "holder": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", "verifiableCredential": [ { "@context": [ "https://www.w3.org/ns/credentials/v2", "https://w3id.org/reachhaven/harbour/credentials/v1/" ], - "type": ["VerifiableCredential", "harbour:NaturalPersonCredential"], + "type": [ + "VerifiableCredential", + "harbour:NaturalPersonCredential" + ], "issuer": "did:web:issuer.example.com", "credentialSubject": { - "id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "id": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", "type": "harbour:NaturalPerson", - "memberOf": "did:web:participant.example.com" + "memberOf": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" } } ] }, "delegatedTo": "did:web:signing-service.envited.io", - "transactionData": { + "transaction_data": { "type": "harbour_delegate:data.purchase", - "credential_ids": ["simpulse_id"], - "transaction_data_hashes_alg": ["sha-256"], + "credential_ids": [ + "simpulse_id" + ], + "transaction_data_hashes_alg": [ + "sha-256" + ], "nonce": "da9b1009", "iat": 1771934400, "txn": { - "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", "marketplace": "did:web:dataspace.envited.io" } - } + }, + "challenge": "da9b1009 HARBOUR_DELEGATE c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f" } ] } diff --git a/examples/did-webs/README.md b/examples/did-webs/README.md new file mode 100644 index 0000000..db78fa0 --- /dev/null +++ b/examples/did-webs/README.md @@ -0,0 +1,34 @@ +# did:webs Example Documents + +This folder contains static example DID documents for the `did:webs` identifiers +used in `examples/*.json`. + +## Scope and Boundary + +- These files are modeling examples for Harbour v1. +- This repository does **not** resolve `did:webs` identifiers and does **not** + validate `keri.cesr` streams. +- Integrators must host corresponding `did.json` and `keri.cesr` resources in + production according to the `did:webs` method specification. + +## Naming Policy in These Examples + +- Natural person identifiers use a UUID path segment and do not carry real + names in the DID path. +- Legal person identifiers may use an organization suffix (for example + `bmw_ag`) in the DID path. + +## Example IDs + +- Natural person: + `did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP` +- Legal person: + `did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe` +- EmailPass issuer (legal person): + `did:webs:issuers.altme.example:legal-persons:altme_sas:EMtR9m3wZ5xV2k8sP4jQ7nH1cD6bL0fYgAaUu2hCqK9M` + +## EmailPass Modeling + +EmailPass is modeled as a verifiable credential used inside `CredentialEvidence`. +It is **not** a DID verification relationship. The binding to `did:webs` comes from +the EmailPass VC issuer DID and its DID document in this folder. diff --git a/examples/did-webs/legal-person-altme_sas.did.json b/examples/did-webs/legal-person-altme_sas.did.json new file mode 100644 index 0000000..7faee09 --- /dev/null +++ b/examples/did-webs/legal-person-altme_sas.did.json @@ -0,0 +1,27 @@ +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/jwk/v1" + ], + "id": "did:webs:issuers.altme.example:legal-persons:altme_sas:EMtR9m3wZ5xV2k8sP4jQ7nH1cD6bL0fYgAaUu2hCqK9M", + "controller": "did:webs:issuers.altme.example:legal-persons:altme_sas:EMtR9m3wZ5xV2k8sP4jQ7nH1cD6bL0fYgAaUu2hCqK9M", + "verificationMethod": [ + { + "id": "did:webs:issuers.altme.example:legal-persons:altme_sas:EMtR9m3wZ5xV2k8sP4jQ7nH1cD6bL0fYgAaUu2hCqK9M#key-1", + "type": "JsonWebKey2020", + "controller": "did:webs:issuers.altme.example:legal-persons:altme_sas:EMtR9m3wZ5xV2k8sP4jQ7nH1cD6bL0fYgAaUu2hCqK9M", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "6IV8gKy5sZtpBj0cmcLMvvYQElrmSMcZ1j3YyOb_KwQ", + "y": "bDwerXTq4pnsPuhO0RFYhmouOeF0p8FdSBcwzMpvGU0" + } + } + ], + "authentication": [ + "#key-1" + ], + "assertionMethod": [ + "#key-1" + ] +} diff --git a/examples/did-webs/legal-person-bmw_ag.did.json b/examples/did-webs/legal-person-bmw_ag.did.json new file mode 100644 index 0000000..19dd5f4 --- /dev/null +++ b/examples/did-webs/legal-person-bmw_ag.did.json @@ -0,0 +1,27 @@ +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/jwk/v1" + ], + "id": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "controller": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "verificationMethod": [ + { + "id": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe#key-1", + "type": "JsonWebKey2020", + "controller": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "6IV8gKy5sZtpBj0cmcLMvvYQElrmSMcZ1j3YyOb_KwQ", + "y": "bDwerXTq4pnsPuhO0RFYhmouOeF0p8FdSBcwzMpvGU0" + } + } + ], + "authentication": [ + "#key-1" + ], + "assertionMethod": [ + "#key-1" + ] +} diff --git a/examples/did-webs/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json b/examples/did-webs/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json new file mode 100644 index 0000000..c4fbabb --- /dev/null +++ b/examples/did-webs/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json @@ -0,0 +1,27 @@ +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/jwk/v1" + ], + "id": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", + "controller": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", + "verificationMethod": [ + { + "id": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP#key-1", + "type": "JsonWebKey2020", + "controller": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "6IV8gKy5sZtpBj0cmcLMvvYQElrmSMcZ1j3YyOb_KwQ", + "y": "bDwerXTq4pnsPuhO0RFYhmouOeF0p8FdSBcwzMpvGU0" + } + } + ], + "authentication": [ + "#key-1" + ], + "assertionMethod": [ + "#key-1" + ] +} diff --git a/examples/legal-person-credential.json b/examples/legal-person-credential.json index 9827a5e..a6e67bd 100644 --- a/examples/legal-person-credential.json +++ b/examples/legal-person-credential.json @@ -13,7 +13,7 @@ "validFrom": "2024-01-15T00:00:00Z", "validUntil": "2025-01-15T00:00:00Z", "credentialSubject": { - "id": "did:web:participant.example.com", + "id": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", "type": "harbour:LegalPerson", "name": "Example Corporation GmbH", "gxParticipant": { @@ -47,7 +47,7 @@ "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:web:participant.example.com", + "holder": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", "verifiableCredential": [ { "@context": [ @@ -58,7 +58,7 @@ "issuer": "did:web:notary.example.com", "validFrom": "2024-01-10T00:00:00Z", "credentialSubject": { - "id": "did:web:participant.example.com", + "id": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", "type": "gx:LegalPerson", "gx:legalName": "Example Corporation GmbH", "gx:registrationNumber": "DE123456789" diff --git a/examples/natural-person-credential.json b/examples/natural-person-credential.json index 92310ea..bd50488 100644 --- a/examples/natural-person-credential.json +++ b/examples/natural-person-credential.json @@ -13,13 +13,13 @@ "validFrom": "2024-01-15T00:00:00Z", "validUntil": "2025-01-15T00:00:00Z", "credentialSubject": { - "id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "id": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", "type": "harbour:NaturalPerson", "name": "Alice Smith", "schema:givenName": "Alice", "schema:familyName": "Smith", "schema:email": "alice.smith@example.com", - "memberOf": "did:web:participant.example.com", + "memberOf": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", "gxParticipant": { "type": "gx:Participant", "schema:name": "Alice Smith" @@ -38,15 +38,15 @@ "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "holder": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", "verifiableCredential": [ { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiableCredential"], - "issuer": "did:web:altme.io", + "issuer": "did:webs:issuers.altme.example:legal-persons:altme_sas:EMtR9m3wZ5xV2k8sP4jQ7nH1cD6bL0fYgAaUu2hCqK9M", "validFrom": "2024-01-10T00:00:00Z", "credentialSubject": { - "id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "id": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", "type": "EmailPass", "email": "alice.smith@example.com" } diff --git a/linkml/harbour.yaml b/linkml/harbour.yaml index 5a4f7a1..c0a1294 100644 --- a/linkml/harbour.yaml +++ b/linkml/harbour.yaml @@ -109,13 +109,13 @@ slots: required: false # --- Delegated Signature Evidence Slots --- - transactionData: + transaction_data: description: > OID4VP-aligned transaction data object (§8.4). Contains action type, credential IDs, timestamps, and action-specific details in the txn field. On the receipt SD-JWT-VC this is a selectively disclosable claim enabling three-layer privacy (public / authorized / full audit). - slot_uri: harbour:transactionData + slot_uri: harbour:transaction_data range: Any required: false @@ -284,13 +284,13 @@ classes: slots: - verifiablePresentation - delegatedTo - - transactionData + - transaction_data slot_usage: verifiablePresentation: required: true delegatedTo: required: true - transactionData: + transaction_data: required: true CRSetEntry: diff --git a/src/python/harbour/delegation.py b/src/python/harbour/delegation.py index 9ae9f86..82624a8 100644 --- a/src/python/harbour/delegation.py +++ b/src/python/harbour/delegation.py @@ -21,6 +21,7 @@ from __future__ import annotations import argparse +import base64 import hashlib import json import secrets @@ -199,7 +200,7 @@ def create_delegation_challenge(transaction_data: TransactionData) -> str: Example: >>> tx = TransactionData.create( ... action="data.purchase", - ... txn={"assetId": "urn:uuid:...", "price": "100"}, + ... txn={"asset_id": "urn:uuid:...", "price": "100"}, ... ) >>> challenge = create_delegation_challenge(tx) >>> print(challenge) @@ -209,6 +210,29 @@ def create_delegation_challenge(transaction_data: TransactionData) -> str: return f"{transaction_data.nonce} {ACTION_TYPE} {tx_hash}" +def encode_transaction_data_param(transaction_data: TransactionData) -> str: + """Encode transaction_data object to OID4VP request parameter string. + + OID4VP transmits transaction_data as base64url-encoded JSON strings. + Harbour uses canonical JSON serialization to ensure deterministic outputs + across Python and TypeScript when generating this value. + """ + canonical = transaction_data.to_json(canonical=True).encode("utf-8") + return base64.urlsafe_b64encode(canonical).rstrip(b"=").decode("ascii") + + +def compute_transaction_data_param_hash(transaction_data: TransactionData) -> str: + """Compute OID4VP transaction_data_hashes value for a transaction_data object. + + Per OID4VP Appendix B.3.3.1, the hash is computed over the transaction_data + request string itself (the base64url-encoded JSON object), and then + base64url-encoded. + """ + transaction_data_param = encode_transaction_data_param(transaction_data) + digest = hashlib.sha256(transaction_data_param.encode("ascii")).digest() + return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + + def parse_delegation_challenge(challenge: str) -> tuple[str, str, str]: """Parse a Harbour delegation challenge string. @@ -455,7 +479,7 @@ def main(): # Build transaction dict from args txn = {} if args.asset_id: - txn["assetId"] = args.asset_id + txn["asset_id"] = args.asset_id if args.price: txn["price"] = args.price if args.currency: diff --git a/src/python/harbour/sd_jwt_vp.py b/src/python/harbour/sd_jwt_vp.py index c6eb39a..0f0df13 100644 --- a/src/python/harbour/sd_jwt_vp.py +++ b/src/python/harbour/sd_jwt_vp.py @@ -27,6 +27,7 @@ import json import sys import time +from copy import deepcopy from pathlib import Path from harbour._crypto import import_private_key as _import_private_key @@ -35,12 +36,122 @@ from harbour._crypto import load_public_key as _load_public_key from harbour._crypto import resolve_private_key_alg as _resolve_alg from harbour._crypto import resolve_public_key_alg as _alg_for_key +from harbour.delegation import ( + TransactionData, + compute_transaction_data_param_hash, + create_delegation_challenge, +) from harbour.keys import PrivateKey, PublicKeyType from harbour.verifier import VerificationError from joserfc import jws # SD-JWT uses ~-delimited format SD_JWT_SEPARATOR = "~" +DELEGATED_EVIDENCE_TYPES = { + "DelegatedSignatureEvidence", + "harbour:DelegatedSignatureEvidence", +} + + +def _dedupe(values: list[str]) -> list[str]: + """Return values in first-seen order without duplicates.""" + return list(dict.fromkeys(values)) + + +def _get_transaction_data( + evidence_item: dict, exception_type: type[Exception] +) -> dict[str, object]: + """Extract transaction data from delegated signing evidence.""" + transaction_data = evidence_item.get("transaction_data") + if transaction_data is None: + raise exception_type("DelegatedSignatureEvidence requires transaction_data") + if not isinstance(transaction_data, dict): + raise exception_type( + "DelegatedSignatureEvidence transaction data must be an object" + ) + return transaction_data + + +def _normalize_delegation_evidence( + evidence: list[dict] | None, +) -> tuple[list[dict] | None, list[str], list[str], list[str]]: + """Derive and inject challenge/hash bindings for delegated evidence.""" + if evidence is None: + return None, [], [], [] + + normalized: list[dict] = [] + tx_hashes: list[str] = [] + tx_nonces: list[str] = [] + delegated_to_values: list[str] = [] + + for item in evidence: + ev = deepcopy(item) + if ev.get("type") in DELEGATED_EVIDENCE_TYPES: + transaction_data = _get_transaction_data(ev, ValueError) + tx = TransactionData.from_dict(transaction_data) + challenge = create_delegation_challenge(tx) + existing_challenge = ev.get("challenge") + if ( + existing_challenge is not None + and isinstance(existing_challenge, str) + and existing_challenge != challenge + ): + raise ValueError( + "DelegatedSignatureEvidence challenge does not match transaction_data" + ) + ev["challenge"] = challenge + + tx_hashes.append(compute_transaction_data_param_hash(tx)) + tx_nonces.append(tx.nonce) + + delegated_to = ev.get("delegatedTo") + if isinstance(delegated_to, str): + delegated_to_values.append(delegated_to) + + normalized.append(ev) + + return ( + normalized, + _dedupe(tx_hashes), + _dedupe(tx_nonces), + _dedupe(delegated_to_values), + ) + + +def _derive_delegation_bindings( + evidence: list[dict] | None, +) -> tuple[list[str], list[str], list[str]]: + """Derive expected hash/nonce/audience bindings from delegated evidence.""" + if not evidence: + return [], [], [] + + tx_hashes: list[str] = [] + tx_nonces: list[str] = [] + delegated_to_values: list[str] = [] + + for item in evidence: + if item.get("type") in DELEGATED_EVIDENCE_TYPES: + transaction_data = _get_transaction_data(item, VerificationError) + tx = TransactionData.from_dict(transaction_data) + expected_challenge = create_delegation_challenge(tx) + provided_challenge = item.get("challenge") + if ( + provided_challenge is not None + and isinstance(provided_challenge, str) + and provided_challenge != expected_challenge + ): + raise VerificationError( + "Delegation challenge mismatch in evidence transaction_data" + ) + + tx_hashes.append(compute_transaction_data_param_hash(tx)) + tx_nonces.append(tx.nonce) + + delegated_to = item.get("delegatedTo") + if isinstance(delegated_to, str): + delegated_to_values.append(delegated_to) + + return _dedupe(tx_hashes), _dedupe(tx_nonces), _dedupe(delegated_to_values) def issue_sd_jwt_vp( @@ -68,7 +179,7 @@ def issue_sd_jwt_vp( If empty list [], includes no disclosures (max privacy). evidence: Evidence objects to include in the VP. Supported types: - CredentialEvidence: prior credential/VP the issuer relied upon - - DelegatedSignatureEvidence: consent proof with transactionData + - DelegatedSignatureEvidence: consent proof with transaction_data nonce: Challenge nonce for replay protection. audience: Intended verifier (DID or URL). holder_did: Holder's DID for the VP. If not provided, will not be included. @@ -115,6 +226,38 @@ def issue_sd_jwt_vp( if name in disclosure_map: selected_disclosures.append(disclosure_map[name]) + normalized_evidence, tx_hashes, tx_nonces, delegated_to_values = ( + _normalize_delegation_evidence(evidence) + ) + + resolved_nonce = nonce + if tx_nonces: + if resolved_nonce is None: + if len(tx_nonces) != 1: + raise ValueError( + "DelegatedSignatureEvidence contains multiple transaction_data nonce values; " + "pass explicit nonce" + ) + resolved_nonce = tx_nonces[0] + elif any(tx_nonce != resolved_nonce for tx_nonce in tx_nonces): + raise ValueError( + "Nonce must match DelegatedSignatureEvidence transaction_data.nonce" + ) + + resolved_audience = audience + if delegated_to_values: + if resolved_audience is None: + if len(delegated_to_values) != 1: + raise ValueError( + "DelegatedSignatureEvidence contains multiple delegatedTo values; " + "pass explicit audience" + ) + resolved_audience = delegated_to_values[0] + elif any(value != resolved_audience for value in delegated_to_values): + raise ValueError( + "Audience must match DelegatedSignatureEvidence delegatedTo" + ) + # Build VP payload vp_payload = { "vp": { @@ -128,14 +271,14 @@ def issue_sd_jwt_vp( vp_payload["vp"]["holder"] = holder_did vp_payload["iss"] = holder_did - if nonce: - vp_payload["nonce"] = nonce + if resolved_nonce: + vp_payload["nonce"] = resolved_nonce - if audience: - vp_payload["aud"] = audience + if resolved_audience: + vp_payload["aud"] = resolved_audience - if evidence: - vp_payload["vp"]["evidence"] = evidence + if normalized_evidence: + vp_payload["vp"]["evidence"] = normalized_evidence # Include reference to the VC (the issuer JWT will be reconstructed on verify) # We store a hash of the issuer JWT for binding @@ -168,10 +311,13 @@ def issue_sd_jwt_vp( .decode(), } - if nonce: - kb_payload["nonce"] = nonce - if audience: - kb_payload["aud"] = audience + if resolved_nonce: + kb_payload["nonce"] = resolved_nonce + if resolved_audience: + kb_payload["aud"] = resolved_audience + if tx_hashes: + kb_payload["transaction_data_hashes"] = tx_hashes + kb_payload["transaction_data_hashes_alg"] = "sha-256" kb_header = {"alg": alg, "typ": "kb+jwt"} kb_payload_bytes = json.dumps(kb_payload, ensure_ascii=False).encode("utf-8") @@ -293,22 +439,72 @@ def verify_sd_jwt_vp( if kb_payload.get("sd_hash") != expected_sd_hash: raise VerificationError("SD hash mismatch in KB-JWT") + vp_nonce = vp_payload.get("nonce") + kb_nonce = kb_payload.get("nonce") + if vp_nonce != kb_nonce and (vp_nonce is not None or kb_nonce is not None): + raise VerificationError("Nonce mismatch between VP and KB-JWT") + + vp_audience = vp_payload.get("aud") + kb_audience = kb_payload.get("aud") + if vp_audience != kb_audience and ( + vp_audience is not None or kb_audience is not None + ): + raise VerificationError("Audience mismatch between VP and KB-JWT") + + vp_obj = vp_payload.get("vp", {}) + evidence = vp_obj.get("evidence") if isinstance(vp_obj, dict) else None + evidence_list = evidence if isinstance(evidence, list) else None + tx_hashes, tx_nonces, delegated_to_values = _derive_delegation_bindings( + evidence_list + ) + + if tx_hashes: + kb_hashes = kb_payload.get("transaction_data_hashes") + if not isinstance(kb_hashes, list) or not all( + isinstance(item, str) for item in kb_hashes + ): + raise VerificationError( + "Missing transaction_data_hashes in KB-JWT for delegated evidence" + ) + if kb_hashes != tx_hashes: + raise VerificationError("transaction_data_hashes mismatch") + if kb_payload.get("transaction_data_hashes_alg") != "sha-256": + raise VerificationError("transaction_data_hashes_alg must be 'sha-256'") + + if len(tx_nonces) > 1: + raise VerificationError( + "DelegatedSignatureEvidence contains multiple transaction_data nonce values" + ) + if tx_nonces and vp_nonce != tx_nonces[0]: + raise VerificationError( + "Nonce mismatch: VP/KB nonce does not match transaction_data nonce" + ) + + if len(delegated_to_values) > 1: + raise VerificationError( + "DelegatedSignatureEvidence contains multiple delegatedTo values" + ) + if delegated_to_values and vp_audience != delegated_to_values[0]: + raise VerificationError( + "Audience mismatch: VP/KB audience does not match delegatedTo" + ) + # 6. Verify nonce if expected_nonce is not None: - if vp_payload.get("nonce") != expected_nonce: + if vp_nonce != expected_nonce: raise VerificationError( - f"Nonce mismatch: expected {expected_nonce!r}, got {vp_payload.get('nonce')!r}" + f"Nonce mismatch: expected {expected_nonce!r}, got {vp_nonce!r}" ) - if kb_payload.get("nonce") != expected_nonce: + if kb_nonce != expected_nonce: raise VerificationError("Nonce mismatch in KB-JWT") # 7. Verify audience if expected_audience is not None: - if vp_payload.get("aud") != expected_audience: + if vp_audience != expected_audience: raise VerificationError( - f"Audience mismatch: expected {expected_audience!r}, got {vp_payload.get('aud')!r}" + f"Audience mismatch: expected {expected_audience!r}, got {vp_audience!r}" ) - if kb_payload.get("aud") != expected_audience: + if kb_audience != expected_audience: raise VerificationError("Audience mismatch in KB-JWT") # 8. Process disclosures @@ -340,7 +536,6 @@ def verify_sd_jwt_vp( disclosed_claims[claim_name] = claim_value # Build result - vp_obj = vp_payload.get("vp", {}) result = { "credential": disclosed_claims, } diff --git a/src/typescript/harbour/delegation.ts b/src/typescript/harbour/delegation.ts index a49c3be..87b2ceb 100644 --- a/src/typescript/harbour/delegation.ts +++ b/src/typescript/harbour/delegation.ts @@ -141,6 +141,40 @@ export async function computeTransactionHash( .join(""); } +/** + * Encode transaction_data object to OID4VP request parameter string. + * + * OID4VP transmits transaction_data as base64url-encoded JSON strings. + * Harbour uses canonical JSON serialization to ensure deterministic outputs + * across Python and TypeScript when generating this value. + */ +export function encodeTransactionDataParam(td: TransactionData): string { + const canonical = toCanonicalJson(td); + return Buffer.from(canonical, "utf-8") + .toString("base64url") + .replace(/=+$/, ""); +} + +/** + * Compute OID4VP transaction_data_hashes value for a transaction_data object. + * + * Per OID4VP Appendix B.3.3.1, the hash is computed over the transaction_data + * request string itself (the base64url-encoded JSON object), and then + * base64url-encoded. + */ +export async function computeTransactionDataParamHash( + td: TransactionData +): Promise { + const transactionDataParam = encodeTransactionDataParam(td); + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(transactionDataParam) + ); + return Buffer.from(new Uint8Array(digest)) + .toString("base64url") + .replace(/=+$/, ""); +} + /** * Create a Harbour delegation challenge string. * diff --git a/src/typescript/harbour/kb-jwt.ts b/src/typescript/harbour/kb-jwt.ts index 5081284..58ee988 100644 --- a/src/typescript/harbour/kb-jwt.ts +++ b/src/typescript/harbour/kb-jwt.ts @@ -13,7 +13,7 @@ const SD_JWT_SEPARATOR = "~"; export interface KbJwtOptions { nonce: string; audience: string; - transactionData?: string[]; + transaction_data?: string[]; } export interface KbJwtPayload { @@ -32,7 +32,7 @@ export interface KbJwtPayload { * * @param sdJwt - The SD-JWT compact string (ending with ~). * @param holderPrivateKey - Holder's private key. - * @param options - KB-JWT options (nonce, audience, transactionData). + * @param options - KB-JWT options (nonce, audience, transaction_data). * @returns Complete SD-JWT-VC + KB-JWT string. */ export async function createKbJwt( @@ -40,7 +40,7 @@ export async function createKbJwt( holderPrivateKey: CryptoKey, options: KbJwtOptions ): Promise { - const { nonce, audience, transactionData } = options; + const { nonce, audience, transaction_data } = options; // Compute sd_hash (SHA-256 of the issuer-jwt part) const issuerJwt = sdJwt.split(SD_JWT_SEPARATOR)[0]; @@ -56,9 +56,9 @@ export async function createKbJwt( sd_hash: sdHash, }; - if (transactionData && transactionData.length > 0) { + if (transaction_data && transaction_data.length > 0) { const tdHashes: string[] = []; - for (const td of transactionData) { + for (const td of transaction_data) { const tdBytes = new TextEncoder().encode(td); const tdHash = await crypto.subtle.digest("SHA-256", tdBytes); tdHashes.push(base64urlEncode(new Uint8Array(tdHash))); @@ -90,7 +90,7 @@ export class KbJwtVerificationError extends Error { export interface KbJwtVerifyOptions { expectedNonce: string; expectedAudience: string; - expectedTransactionData?: string[]; + expected_transaction_data?: string[]; } /** @@ -107,7 +107,7 @@ export async function verifyKbJwt( holderPublicKey: CryptoKey, options: KbJwtVerifyOptions ): Promise { - const { expectedNonce, expectedAudience, expectedTransactionData } = options; + const { expectedNonce, expectedAudience, expected_transaction_data } = options; // Split: the KB-JWT is the last segment const parts = sdJwtWithKb.split(SD_JWT_SEPARATOR); @@ -166,9 +166,9 @@ export async function verifyKbJwt( } // Verify transaction_data_hashes if expected - if (expectedTransactionData && expectedTransactionData.length > 0) { + if (expected_transaction_data && expected_transaction_data.length > 0) { const expectedHashes: string[] = []; - for (const td of expectedTransactionData) { + for (const td of expected_transaction_data) { const tdBytes = new TextEncoder().encode(td); const tdHash = await crypto.subtle.digest("SHA-256", tdBytes); expectedHashes.push(base64urlEncode(new Uint8Array(tdHash))); diff --git a/src/typescript/harbour/sd-jwt-vp.ts b/src/typescript/harbour/sd-jwt-vp.ts index 0cc8393..90e0142 100644 --- a/src/typescript/harbour/sd-jwt-vp.ts +++ b/src/typescript/harbour/sd-jwt-vp.ts @@ -11,9 +11,18 @@ */ import { CompactSign, compactVerify } from "jose"; +import { + computeTransactionDataParamHash, + createDelegationChallenge, + type TransactionData, +} from "./delegation.js"; import { VerificationError } from "./verifier.js"; const SD_JWT_SEPARATOR = "~"; +const DELEGATED_EVIDENCE_TYPES = new Set([ + "DelegatedSignatureEvidence", + "harbour:DelegatedSignatureEvidence", +]); export interface IssueSdJwtVpOptions { /** Which disclosures to include by claim name. null = all, [] = none. */ @@ -91,13 +100,49 @@ export async function issueSdJwtVp( } } + const delegationBindings = await normalizeDelegationEvidenceForIssue( + options.evidence + ); + + let resolvedNonce = options.nonce; + if (delegationBindings.txNonces.length > 0) { + if (resolvedNonce === undefined) { + if (delegationBindings.txNonces.length !== 1) { + throw new Error( + "DelegatedSignatureEvidence contains multiple transaction_data nonce values; pass explicit nonce" + ); + } + resolvedNonce = delegationBindings.txNonces[0]; + } else if (delegationBindings.txNonces.some((n) => n !== resolvedNonce)) { + throw new Error( + "Nonce must match DelegatedSignatureEvidence transaction_data.nonce" + ); + } + } + + let resolvedAudience = options.audience; + if (delegationBindings.delegatedTo.length > 0) { + if (resolvedAudience === undefined) { + if (delegationBindings.delegatedTo.length !== 1) { + throw new Error( + "DelegatedSignatureEvidence contains multiple delegatedTo values; pass explicit audience" + ); + } + resolvedAudience = delegationBindings.delegatedTo[0]; + } else if (delegationBindings.delegatedTo.some((a) => a !== resolvedAudience)) { + throw new Error("Audience must match DelegatedSignatureEvidence delegatedTo"); + } + } + // Build VP payload const vpPayload: Record = { vp: { "@context": ["https://www.w3.org/ns/credentials/v2"], type: ["VerifiablePresentation"], ...(options.holderDid ? { holder: options.holderDid } : {}), - ...(options.evidence ? { evidence: options.evidence } : {}), + ...(delegationBindings.evidence && delegationBindings.evidence.length > 0 + ? { evidence: delegationBindings.evidence } + : {}), }, iat: Math.floor(Date.now() / 1000), }; @@ -105,11 +150,11 @@ export async function issueSdJwtVp( if (options.holderDid) { vpPayload.iss = options.holderDid; } - if (options.nonce) { - vpPayload.nonce = options.nonce; + if (resolvedNonce) { + vpPayload.nonce = resolvedNonce; } - if (options.audience) { - vpPayload.aud = options.audience; + if (resolvedAudience) { + vpPayload.aud = resolvedAudience; } // Hash of the issuer JWT for binding @@ -138,8 +183,12 @@ export async function issueSdJwtVp( iat: Math.floor(Date.now() / 1000), sd_hash: base64urlEncode(new Uint8Array(sdHashBuffer)), }; - if (options.nonce) kbPayload.nonce = options.nonce; - if (options.audience) kbPayload.aud = options.audience; + if (resolvedNonce) kbPayload.nonce = resolvedNonce; + if (resolvedAudience) kbPayload.aud = resolvedAudience; + if (delegationBindings.txHashes.length > 0) { + kbPayload.transaction_data_hashes = delegationBindings.txHashes; + kbPayload.transaction_data_hashes_alg = "sha-256"; + } const kbPayloadBytes = new TextEncoder().encode(JSON.stringify(kbPayload)); const kbSigner = new CompactSign(kbPayloadBytes); @@ -193,7 +242,9 @@ export async function verifySdJwtVp( ); } - const vpPayload = JSON.parse(new TextDecoder().decode(vpResult.payload)); + const vpPayload = JSON.parse( + new TextDecoder().decode(vpResult.payload) + ) as Record; // 2. Verify issuer JWT (issuer) let vcResult; @@ -211,7 +262,9 @@ export async function verifySdJwtVp( ); } - const vcPayload = JSON.parse(new TextDecoder().decode(vcResult.payload)); + const vcPayload = JSON.parse( + new TextDecoder().decode(vcResult.payload) + ) as Record; // 3. Verify KB-JWT (holder) let kbResult; @@ -229,7 +282,9 @@ export async function verifySdJwtVp( ); } - const kbPayload = JSON.parse(new TextDecoder().decode(kbResult.payload)); + const kbPayload = JSON.parse( + new TextDecoder().decode(kbResult.payload) + ) as Record; // 4. Verify VC hash binding const expectedVcHash = base64urlEncode( @@ -265,32 +320,102 @@ export async function verifySdJwtVp( throw new VerificationError("SD hash mismatch in KB-JWT"); } + const vpNonce = typeof vpPayload.nonce === "string" ? vpPayload.nonce : undefined; + const kbNonce = typeof kbPayload.nonce === "string" ? kbPayload.nonce : undefined; + if (vpNonce !== kbNonce && (vpNonce !== undefined || kbNonce !== undefined)) { + throw new VerificationError("Nonce mismatch between VP and KB-JWT"); + } + + const vpAudience = typeof vpPayload.aud === "string" ? vpPayload.aud : undefined; + const kbAudience = + typeof kbPayload.aud === "string" ? kbPayload.aud : undefined; + if ( + vpAudience !== kbAudience && + (vpAudience !== undefined || kbAudience !== undefined) + ) { + throw new VerificationError("Audience mismatch between VP and KB-JWT"); + } + + const vpObj = isRecord(vpPayload.vp) ? vpPayload.vp : {}; + const evidence = Array.isArray(vpObj.evidence) + ? (vpObj.evidence as unknown[]) + : undefined; + const delegationBindings = await deriveDelegationBindingsForVerify(evidence); + + if (delegationBindings.txHashes.length > 0) { + const kbHashes = kbPayload.transaction_data_hashes; + if ( + !Array.isArray(kbHashes) || + !kbHashes.every((value) => typeof value === "string") + ) { + throw new VerificationError( + "Missing transaction_data_hashes in KB-JWT for delegated evidence" + ); + } + if (!stringArraysEqual(kbHashes as string[], delegationBindings.txHashes)) { + throw new VerificationError("transaction_data_hashes mismatch"); + } + if (kbPayload.transaction_data_hashes_alg !== "sha-256") { + throw new VerificationError("transaction_data_hashes_alg must be 'sha-256'"); + } + } + + if (delegationBindings.txNonces.length > 1) { + throw new VerificationError( + "DelegatedSignatureEvidence contains multiple transaction_data nonce values" + ); + } + if ( + delegationBindings.txNonces.length === 1 && + vpNonce !== delegationBindings.txNonces[0] + ) { + throw new VerificationError( + "Nonce mismatch: VP/KB nonce does not match transaction_data nonce" + ); + } + + if (delegationBindings.delegatedTo.length > 1) { + throw new VerificationError( + "DelegatedSignatureEvidence contains multiple delegatedTo values" + ); + } + if ( + delegationBindings.delegatedTo.length === 1 && + vpAudience !== delegationBindings.delegatedTo[0] + ) { + throw new VerificationError( + "Audience mismatch: VP/KB audience does not match delegatedTo" + ); + } + // 6. Verify nonce if (options.expectedNonce !== undefined) { - if (vpPayload.nonce !== options.expectedNonce) { + if (vpNonce !== options.expectedNonce) { throw new VerificationError( - `Nonce mismatch: expected '${options.expectedNonce}', got '${vpPayload.nonce}'` + `Nonce mismatch: expected '${options.expectedNonce}', got '${vpNonce}'` ); } - if (kbPayload.nonce !== options.expectedNonce) { + if (kbNonce !== options.expectedNonce) { throw new VerificationError("Nonce mismatch in KB-JWT"); } } // 7. Verify audience if (options.expectedAudience !== undefined) { - if (vpPayload.aud !== options.expectedAudience) { + if (vpAudience !== options.expectedAudience) { throw new VerificationError( - `Audience mismatch: expected '${options.expectedAudience}', got '${vpPayload.aud}'` + `Audience mismatch: expected '${options.expectedAudience}', got '${vpAudience}'` ); } - if (kbPayload.aud !== options.expectedAudience) { + if (kbAudience !== options.expectedAudience) { throw new VerificationError("Audience mismatch in KB-JWT"); } } // 8. Process disclosures - const sdDigests = new Set(vcPayload._sd ?? []); + const sdDigests = new Set( + Array.isArray(vcPayload._sd) ? (vcPayload._sd as string[]) : [] + ); const disclosedClaims: Record = {}; for (const [k, v] of Object.entries(vcPayload)) { if (k !== "_sd" && k !== "_sd_alg") { @@ -328,16 +453,15 @@ export async function verifySdJwtVp( } // Build result - const vpObj = (vpPayload.vp ?? {}) as Record; const result: SdJwtVpResult = { credential: disclosedClaims, }; - if (vpObj.holder) result.holder = vpObj.holder as string; - if (vpObj.evidence) + if (typeof vpObj.holder === "string") result.holder = vpObj.holder; + if (Array.isArray(vpObj.evidence)) result.evidence = vpObj.evidence as Record[]; - if (vpPayload.nonce) result.nonce = vpPayload.nonce as string; - if (vpPayload.aud) result.audience = vpPayload.aud as string; + if (vpNonce) result.nonce = vpNonce; + if (vpAudience) result.audience = vpAudience; return result; } @@ -361,3 +485,135 @@ function base64urlEncode(bytes: Uint8Array): string { function base64urlDecode(s: string): Uint8Array { return new Uint8Array(Buffer.from(s, "base64url")); } + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function dedupe(values: string[]): string[] { + return [...new Set(values)]; +} + +function stringArraysEqual(left: string[], right: string[]): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +interface DelegationBindings { + evidence?: Record[]; + txHashes: string[]; + txNonces: string[]; + delegatedTo: string[]; +} + +function getTransactionDataFromEvidence( + evidenceItem: Record, + errorFactory: (message: string) => Error +): Record { + const transactionData = evidenceItem.transaction_data; + if (transactionData === undefined) { + throw errorFactory("DelegatedSignatureEvidence requires transaction_data"); + } + if (!isRecord(transactionData)) { + throw errorFactory("DelegatedSignatureEvidence transaction data must be an object"); + } + return transactionData; +} + +async function normalizeDelegationEvidenceForIssue( + evidence?: Record[] +): Promise { + if (evidence === undefined) { + return { txHashes: [], txNonces: [], delegatedTo: [] }; + } + + const normalized: Record[] = evidence.map((item) => ({ + ...item, + })); + const txHashes: string[] = []; + const txNonces: string[] = []; + const delegatedTo: string[] = []; + + for (const item of normalized) { + if (!DELEGATED_EVIDENCE_TYPES.has(String(item.type))) { + continue; + } + const transactionData = getTransactionDataFromEvidence( + item, + (message) => new Error(message) + ); + const tx = transactionData as unknown as TransactionData; + const challenge = await createDelegationChallenge(tx); + if ( + typeof item.challenge === "string" && + item.challenge !== challenge + ) { + throw new Error( + "DelegatedSignatureEvidence challenge does not match transaction_data" + ); + } + item.challenge = challenge; + + txHashes.push(await computeTransactionDataParamHash(tx)); + txNonces.push(tx.nonce); + + if (typeof item.delegatedTo === "string") { + delegatedTo.push(item.delegatedTo); + } + } + + return { + evidence: normalized, + txHashes: dedupe(txHashes), + txNonces: dedupe(txNonces), + delegatedTo: dedupe(delegatedTo), + }; +} + +async function deriveDelegationBindingsForVerify( + evidence?: unknown[] +): Promise> { + if (!evidence) { + return { txHashes: [], txNonces: [], delegatedTo: [] }; + } + + const txHashes: string[] = []; + const txNonces: string[] = []; + const delegatedTo: string[] = []; + + for (const evidenceItem of evidence) { + if (!isRecord(evidenceItem)) continue; + if (!DELEGATED_EVIDENCE_TYPES.has(String(evidenceItem.type))) continue; + + const transactionData = getTransactionDataFromEvidence( + evidenceItem, + (message) => new VerificationError(message) + ); + const tx = transactionData as unknown as TransactionData; + + const expectedChallenge = await createDelegationChallenge(tx); + if ( + typeof evidenceItem.challenge === "string" && + evidenceItem.challenge !== expectedChallenge + ) { + throw new VerificationError( + "Delegation challenge mismatch in evidence transaction_data" + ); + } + + txHashes.push(await computeTransactionDataParamHash(tx)); + txNonces.push(tx.nonce); + + if (typeof evidenceItem.delegatedTo === "string") { + delegatedTo.push(evidenceItem.delegatedTo); + } + } + + return { + txHashes: dedupe(txHashes), + txNonces: dedupe(txNonces), + delegatedTo: dedupe(delegatedTo), + }; +} diff --git a/tests/fixtures/canonicalization-vectors.json b/tests/fixtures/canonicalization-vectors.json index e4027bf..4c9d34c 100644 --- a/tests/fixtures/canonicalization-vectors.json +++ b/tests/fixtures/canonicalization-vectors.json @@ -2,51 +2,70 @@ "description": "Shared test vectors for cross-runtime canonicalization. Python json.dumps(sort_keys=True, separators=(',',':')) sorts ALL keys recursively. TypeScript must match this exactly.", "vectors": [ { - "name": "data.purchase — minimal required fields", + "name": "data.purchase \u2014 minimal required fields", "input": { "type": "harbour_delegate:data.purchase", - "credential_ids": ["simpulse_id"], - "transaction_data_hashes_alg": ["sha-256"], + "credential_ids": [ + "simpulse_id" + ], + "transaction_data_hashes_alg": [ + "sha-256" + ], "nonce": "da9b1009", "iat": 1771934400, "txn": { - "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", "marketplace": "did:web:dataspace.envited.io" } }, - "canonical_json": "{\"credential_ids\":[\"simpulse_id\"],\"iat\":1771934400,\"nonce\":\"da9b1009\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"assetId\":\"urn:uuid:550e8400-e29b-41d4-a716-446655440000\",\"currency\":\"ENVITED\",\"marketplace\":\"did:web:dataspace.envited.io\",\"price\":\"100\"},\"type\":\"harbour_delegate:data.purchase\"}", - "sha256_hash": "86a3e927a80d9e858a71b37f574aa65cab184c5fc65e8c7824771f84ad6ed97e", - "challenge": "da9b1009 HARBOUR_DELEGATE 86a3e927a80d9e858a71b37f574aa65cab184c5fc65e8c7824771f84ad6ed97e" + "canonical_json": "{\"credential_ids\":[\"simpulse_id\"],\"iat\":1771934400,\"nonce\":\"da9b1009\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"asset_id\":\"urn:uuid:550e8400-e29b-41d4-a716-446655440000\",\"currency\":\"ENVITED\",\"marketplace\":\"did:web:dataspace.envited.io\",\"price\":\"100\"},\"type\":\"harbour_delegate:data.purchase\"}", + "sha256_hash": "c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f", + "challenge": "da9b1009 HARBOUR_DELEGATE c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f", + "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJzaW1wdWxzZV9pZCJdLCJpYXQiOjE3NzE5MzQ0MDAsIm5vbmNlIjoiZGE5YjEwMDkiLCJ0cmFuc2FjdGlvbl9kYXRhX2hhc2hlc19hbGciOlsic2hhLTI1NiJdLCJ0eG4iOnsiYXNzZXRfaWQiOiJ1cm46dXVpZDo1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJjdXJyZW5jeSI6IkVOVklURUQiLCJtYXJrZXRwbGFjZSI6ImRpZDp3ZWI6ZGF0YXNwYWNlLmVudml0ZWQuaW8iLCJwcmljZSI6IjEwMCJ9LCJ0eXBlIjoiaGFyYm91cl9kZWxlZ2F0ZTpkYXRhLnB1cmNoYXNlIn0", + "transaction_data_param_hash": "7W0LFUTpMvb6nJK7ngamNNY0zNvxqJ-2jNXTmLzhWQE" }, { - "name": "contract.sign — with optional exp and description", + "name": "contract.sign \u2014 with optional exp and description", "input": { "type": "harbour_delegate:contract.sign", - "credential_ids": ["org_credential"], - "transaction_data_hashes_alg": ["sha-256"], + "credential_ids": [ + "org_credential" + ], + "transaction_data_hashes_alg": [ + "sha-256" + ], "nonce": "ab12cd34", "iat": 1771934400, "exp": 1771935300, "description": "Sign partnership agreement", "txn": { - "documentHash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "parties": ["did:web:alice.example", "did:web:bob.example"] + "document_hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "parties": [ + "did:web:alice.example", + "did:web:bob.example" + ] } }, - "canonical_json": "{\"credential_ids\":[\"org_credential\"],\"description\":\"Sign partnership agreement\",\"exp\":1771935300,\"iat\":1771934400,\"nonce\":\"ab12cd34\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"documentHash\":\"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"parties\":[\"did:web:alice.example\",\"did:web:bob.example\"]},\"type\":\"harbour_delegate:contract.sign\"}", - "sha256_hash": "daccac20a99de56e2a5d108fcfa53d0d03faa7d4cd29552ae1dbdc486120d3ec", - "challenge": "ab12cd34 HARBOUR_DELEGATE daccac20a99de56e2a5d108fcfa53d0d03faa7d4cd29552ae1dbdc486120d3ec" + "canonical_json": "{\"credential_ids\":[\"org_credential\"],\"description\":\"Sign partnership agreement\",\"exp\":1771935300,\"iat\":1771934400,\"nonce\":\"ab12cd34\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"document_hash\":\"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"parties\":[\"did:web:alice.example\",\"did:web:bob.example\"]},\"type\":\"harbour_delegate:contract.sign\"}", + "sha256_hash": "0863ac13bc5f15c7dfcdee71b8beea1aead4b822d0a7c03154405da4f192af08", + "challenge": "ab12cd34 HARBOUR_DELEGATE 0863ac13bc5f15c7dfcdee71b8beea1aead4b822d0a7c03154405da4f192af08", + "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJvcmdfY3JlZGVudGlhbCJdLCJkZXNjcmlwdGlvbiI6IlNpZ24gcGFydG5lcnNoaXAgYWdyZWVtZW50IiwiZXhwIjoxNzcxOTM1MzAwLCJpYXQiOjE3NzE5MzQ0MDAsIm5vbmNlIjoiYWIxMmNkMzQiLCJ0cmFuc2FjdGlvbl9kYXRhX2hhc2hlc19hbGciOlsic2hhLTI1NiJdLCJ0eG4iOnsiZG9jdW1lbnRfaGFzaCI6InNoYTI1NjplM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1IiwicGFydGllcyI6WyJkaWQ6d2ViOmFsaWNlLmV4YW1wbGUiLCJkaWQ6d2ViOmJvYi5leGFtcGxlIl19LCJ0eXBlIjoiaGFyYm91cl9kZWxlZ2F0ZTpjb250cmFjdC5zaWduIn0", + "transaction_data_param_hash": "Sf_HN8fsqdnrWYq6XlXjmUrUpuASJi2F65ZaPMx6uTk" }, { - "name": "blockchain.transfer — nested txn verifies recursive sort", + "name": "blockchain.transfer \u2014 nested txn verifies recursive sort", "input": { "type": "harbour_delegate:blockchain.transfer", - "credential_ids": ["default"], + "credential_ids": [ + "default" + ], "nonce": "ef567890", "iat": 1771934400, - "transaction_data_hashes_alg": ["sha-256"], + "transaction_data_hashes_alg": [ + "sha-256" + ], "txn": { "chain": "eip155:42793", "amount": "1000000000000000000", @@ -56,7 +75,48 @@ }, "canonical_json": "{\"credential_ids\":[\"default\"],\"iat\":1771934400,\"nonce\":\"ef567890\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"amount\":\"1000000000000000000\",\"chain\":\"eip155:42793\",\"contract\":\"0x1234567890abcdef\",\"recipient\":\"0xabcdef1234567890\"},\"type\":\"harbour_delegate:blockchain.transfer\"}", "sha256_hash": "0736db89c15be412294f96717a3e435f89d095e7e953b1808c422252b845d4c1", - "challenge": "ef567890 HARBOUR_DELEGATE 0736db89c15be412294f96717a3e435f89d095e7e953b1808c422252b845d4c1" + "challenge": "ef567890 HARBOUR_DELEGATE 0736db89c15be412294f96717a3e435f89d095e7e953b1808c422252b845d4c1", + "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJkZWZhdWx0Il0sImlhdCI6MTc3MTkzNDQwMCwibm9uY2UiOiJlZjU2Nzg5MCIsInRyYW5zYWN0aW9uX2RhdGFfaGFzaGVzX2FsZyI6WyJzaGEtMjU2Il0sInR4biI6eyJhbW91bnQiOiIxMDAwMDAwMDAwMDAwMDAwMDAwIiwiY2hhaW4iOiJlaXAxNTU6NDI3OTMiLCJjb250cmFjdCI6IjB4MTIzNDU2Nzg5MGFiY2RlZiIsInJlY2lwaWVudCI6IjB4YWJjZGVmMTIzNDU2Nzg5MCJ9LCJ0eXBlIjoiaGFyYm91cl9kZWxlZ2F0ZTpibG9ja2NoYWluLnRyYW5zZmVyIn0", + "transaction_data_param_hash": "zjzDdgBNR_BsX0TR5nOKIhZLC-RUj92EFJd-bKbgwtw" + }, + { + "name": "blockchain.execute \u2014 deeply nested params and booleans", + "input": { + "type": "harbour_delegate:blockchain.execute", + "credential_ids": [ + "wallet_cred", + "org_cred" + ], + "transaction_data_hashes_alg": [ + "sha-256" + ], + "nonce": "91af4c2e", + "iat": 1771934400, + "description": "Execute settlement", + "txn": { + "chain": "eip155:1", + "contract": "0x1234567890abcdef1234567890abcdef12345678", + "method": "settle", + "params": { + "recipient": "0xAbCdEf1234567890aBCDef1234567890abCDef12", + "amount": "4200000000000000000", + "flags": { + "urgent": true, + "gasless": false + }, + "approvers": [ + "did:web:bmw.example", + "did:web:supplier.example" + ] + }, + "value": "0" + } + }, + "canonical_json": "{\"credential_ids\":[\"wallet_cred\",\"org_cred\"],\"description\":\"Execute settlement\",\"iat\":1771934400,\"nonce\":\"91af4c2e\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"chain\":\"eip155:1\",\"contract\":\"0x1234567890abcdef1234567890abcdef12345678\",\"method\":\"settle\",\"params\":{\"amount\":\"4200000000000000000\",\"approvers\":[\"did:web:bmw.example\",\"did:web:supplier.example\"],\"flags\":{\"gasless\":false,\"urgent\":true},\"recipient\":\"0xAbCdEf1234567890aBCDef1234567890abCDef12\"},\"value\":\"0\"},\"type\":\"harbour_delegate:blockchain.execute\"}", + "sha256_hash": "342ca6347bc0b852460090a1229273ec60695d58e45b5da60139fbcfa2a79fc0", + "challenge": "91af4c2e HARBOUR_DELEGATE 342ca6347bc0b852460090a1229273ec60695d58e45b5da60139fbcfa2a79fc0", + "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJ3YWxsZXRfY3JlZCIsIm9yZ19jcmVkIl0sImRlc2NyaXB0aW9uIjoiRXhlY3V0ZSBzZXR0bGVtZW50IiwiaWF0IjoxNzcxOTM0NDAwLCJub25jZSI6IjkxYWY0YzJlIiwidHJhbnNhY3Rpb25fZGF0YV9oYXNoZXNfYWxnIjpbInNoYS0yNTYiXSwidHhuIjp7ImNoYWluIjoiZWlwMTU1OjEiLCJjb250cmFjdCI6IjB4MTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTBhYmNkZWYxMjM0NTY3OCIsIm1ldGhvZCI6InNldHRsZSIsInBhcmFtcyI6eyJhbW91bnQiOiI0MjAwMDAwMDAwMDAwMDAwMDAwIiwiYXBwcm92ZXJzIjpbImRpZDp3ZWI6Ym13LmV4YW1wbGUiLCJkaWQ6d2ViOnN1cHBsaWVyLmV4YW1wbGUiXSwiZmxhZ3MiOnsiZ2FzbGVzcyI6ZmFsc2UsInVyZ2VudCI6dHJ1ZX0sInJlY2lwaWVudCI6IjB4QWJDZEVmMTIzNDU2Nzg5MGFCQ0RlZjEyMzQ1Njc4OTBhYkNEZWYxMiJ9LCJ2YWx1ZSI6IjAifSwidHlwZSI6ImhhcmJvdXJfZGVsZWdhdGU6YmxvY2tjaGFpbi5leGVjdXRlIn0", + "transaction_data_param_hash": "47JLdelRLXXAa_zuInDHy_zGQA0DrMu0V6W2cqwp1fU" } ] } diff --git a/tests/fixtures/sample-vc.json b/tests/fixtures/sample-vc.json index a8759cd..c6c4e37 100644 --- a/tests/fixtures/sample-vc.json +++ b/tests/fixtures/sample-vc.json @@ -8,7 +8,7 @@ "issuer": "did:web:trust.harbour.example.com", "validFrom": "2025-08-06T10:15:22Z", "credentialSubject": { - "id": "did:web:participant.example.com", + "id": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", "type": "harbour:LegalPerson", "name": "Example Corporation GmbH" } diff --git a/tests/interop/test_cross_runtime.py b/tests/interop/test_cross_runtime.py index 1152751..fbbbaf8 100644 --- a/tests/interop/test_cross_runtime.py +++ b/tests/interop/test_cross_runtime.py @@ -7,7 +7,9 @@ import pytest from harbour.delegation import ( TransactionData, + compute_transaction_data_param_hash, create_delegation_challenge, + encode_transaction_data_param, ) from harbour.sd_jwt import issue_sd_jwt_vc, verify_sd_jwt_vc from harbour.signer import sign_vc_jose, sign_vp_jose @@ -296,6 +298,63 @@ def test_challenge_string_matches(self, vectors): process.exit(1); }} }} +console.log("OK"); + """ + assert _run_node(script) == "OK" + + def test_transaction_data_param_matches(self, vectors): + """Both runtimes produce the same base64url transaction_data request strings.""" + for v in vectors: + td = TransactionData.from_dict(v["input"]) + assert ( + encode_transaction_data_param(td) == v["transaction_data_param"] + ), f"Python mismatch for {v['name']}" + + inputs_json = json.dumps([v["input"] for v in vectors]) + expected_json = json.dumps([v["transaction_data_param"] for v in vectors]) + + script = f""" +import {{ encodeTransactionDataParam }} from "./dist/delegation.js"; +const inputs = {inputs_json}; +const expected = {expected_json}; +for (let i = 0; i < inputs.length; i++) {{ + const encoded = encodeTransactionDataParam(inputs[i]); + if (encoded !== expected[i]) {{ + console.error("MISMATCH at index " + i); + console.error("Got: " + encoded); + console.error("Expected: " + expected[i]); + process.exit(1); + }} +}} +console.log("OK"); +""" + assert _run_node(script) == "OK" + + def test_transaction_data_param_hash_matches(self, vectors): + """Both runtimes produce the same OID4VP transaction_data_hashes values.""" + for v in vectors: + td = TransactionData.from_dict(v["input"]) + assert ( + compute_transaction_data_param_hash(td) + == v["transaction_data_param_hash"] + ), f"Python mismatch for {v['name']}" + + inputs_json = json.dumps([v["input"] for v in vectors]) + expected_json = json.dumps([v["transaction_data_param_hash"] for v in vectors]) + + script = f""" +import {{ computeTransactionDataParamHash }} from "./dist/delegation.js"; +const inputs = {inputs_json}; +const expected = {expected_json}; +for (let i = 0; i < inputs.length; i++) {{ + const hash = await computeTransactionDataParamHash(inputs[i]); + if (hash !== expected[i]) {{ + console.error("MISMATCH at index " + i); + console.error("Got: " + hash); + console.error("Expected: " + expected[i]); + process.exit(1); + }} +}} console.log("OK"); """ assert _run_node(script) == "OK" diff --git a/tests/python/credentials/test_claim_mapping.py b/tests/python/credentials/test_claim_mapping.py index 9a3dc38..9e9e376 100644 --- a/tests/python/credentials/test_claim_mapping.py +++ b/tests/python/credentials/test_claim_mapping.py @@ -32,7 +32,10 @@ def test_vc_to_claims(self): claims, disclosable = vc_to_sd_jwt_claims(vc, mapping) assert claims["iss"] == "did:web:trust-anchor.example.com" - assert claims["sub"] == "did:web:participant.example.com" + assert ( + claims["sub"] + == "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" + ) assert claims["name"] == "Example Corporation GmbH" assert claims["legalName"] == "Example Corporation GmbH" assert "registrationNumber" in claims diff --git a/tests/python/credentials/test_example_signer.py b/tests/python/credentials/test_example_signer.py index 3ad9f49..0f9ea85 100644 --- a/tests/python/credentials/test_example_signer.py +++ b/tests/python/credentials/test_example_signer.py @@ -57,7 +57,7 @@ def test_sign_evidence_vp(self, signing_key): vp = { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:web:participant.example.com", + "holder": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", "verifiableCredential": [ { "@context": ["https://www.w3.org/ns/credentials/v2"], @@ -65,7 +65,7 @@ def test_sign_evidence_vp(self, signing_key): "issuer": "did:web:notary.example.com", "validFrom": "2024-01-10T00:00:00Z", "credentialSubject": { - "id": "did:web:participant.example.com", + "id": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", "type": "gx:LegalPerson", }, } @@ -203,11 +203,11 @@ def test_process_delegated_signing_receipt(self, signing_key, tmp_path): vc_payload = verify_vc_jose(vc_jwt, public_key) assert "harbour:DelegatedSigningReceipt" in vc_payload["type"] - # Evidence should contain DelegatedSignatureEvidence with transactionData + # Evidence should contain DelegatedSignatureEvidence with transaction_data evidence = vc_payload["evidence"][0] assert evidence["type"] == "harbour:DelegatedSignatureEvidence" - assert "transactionData" in evidence - assert evidence["transactionData"]["type"] == "harbour_delegate:data.purchase" + assert "transaction_data" in evidence + assert evidence["transaction_data"]["type"] == "harbour_delegate:data.purchase" assert evidence["delegatedTo"] == "did:web:signing-service.envited.io" # Evidence VP should be a signed JWT diff --git a/tests/python/harbour/test_delegation.py b/tests/python/harbour/test_delegation.py index 9072389..212cb45 100644 --- a/tests/python/harbour/test_delegation.py +++ b/tests/python/harbour/test_delegation.py @@ -26,7 +26,9 @@ ACTION_TYPE, ChallengeError, TransactionData, + compute_transaction_data_param_hash, create_delegation_challenge, + encode_transaction_data_param, parse_delegation_challenge, render_transaction_display, validate_transaction_data, @@ -48,12 +50,12 @@ def test_create_basic(self): """Test basic TransactionData creation.""" tx = TransactionData.create( action="data.purchase", - txn={"assetId": "urn:uuid:test", "price": "100"}, + txn={"asset_id": "urn:uuid:test", "price": "100"}, ) assert tx.type == "harbour_delegate:data.purchase" assert tx.credential_ids == ["default"] - assert tx.txn == {"assetId": "urn:uuid:test", "price": "100"} + assert tx.txn == {"asset_id": "urn:uuid:test", "price": "100"} assert tx.exp is None assert tx.description is None assert tx.transaction_data_hashes_alg == ["sha-256"] @@ -64,7 +66,7 @@ def test_create_with_custom_nonce(self): """Test TransactionData with custom nonce.""" tx = TransactionData.create( action="data.purchase", - txn={"assetId": "test"}, + txn={"asset_id": "test"}, nonce="custom123", ) @@ -74,7 +76,7 @@ def test_create_with_custom_iat(self): """Test TransactionData with custom iat.""" tx = TransactionData.create( action="data.purchase", - txn={"assetId": "test"}, + txn={"asset_id": "test"}, iat=1771934400, ) @@ -84,7 +86,7 @@ def test_create_with_optional_fields(self): """Test TransactionData with optional fields.""" tx = TransactionData.create( action="data.purchase", - txn={"assetId": "test"}, + txn={"asset_id": "test"}, exp=1771935300, description="Test purchase", credential_ids=["simpulse_id"], @@ -98,7 +100,7 @@ def test_action_property(self): """Test action extraction from type field.""" tx = TransactionData.create( action="data.purchase", - txn={"assetId": "test"}, + txn={"asset_id": "test"}, ) assert tx.action == "data.purchase" @@ -110,7 +112,7 @@ def test_to_dict_omits_none(self): credential_ids=["default"], nonce="da9b1009", iat=1771934400, - txn={"assetId": "test", "price": "100"}, + txn={"asset_id": "test", "price": "100"}, ) d = tx.to_dict() @@ -119,7 +121,7 @@ def test_to_dict_omits_none(self): assert d["credential_ids"] == ["default"] assert d["nonce"] == "da9b1009" assert d["iat"] == 1771934400 - assert d["txn"] == {"assetId": "test", "price": "100"} + assert d["txn"] == {"asset_id": "test", "price": "100"} assert "exp" not in d assert "description" not in d @@ -130,7 +132,7 @@ def test_to_dict_includes_optional_when_present(self): credential_ids=["simpulse_id"], nonce="da9b1009", iat=1771934400, - txn={"assetId": "test"}, + txn={"asset_id": "test"}, exp=1771935300, description="Test purchase", ) @@ -165,7 +167,7 @@ def test_to_json_pretty(self): credential_ids=["default"], nonce="da9b1009", iat=1771934400, - txn={"assetId": "test"}, + txn={"asset_id": "test"}, ) json_str = tx.to_json(canonical=False) @@ -183,7 +185,7 @@ def test_from_dict(self): "iat": 1771934400, "exp": 1771935300, "description": "Sign agreement", - "txn": {"documentHash": "sha256:abc123"}, + "txn": {"document_hash": "sha256:abc123"}, "transaction_data_hashes_alg": ["sha-256"], } @@ -195,7 +197,7 @@ def test_from_dict(self): assert tx.iat == 1771934400 assert tx.exp == 1771935300 assert tx.description == "Sign agreement" - assert tx.txn["documentHash"] == "sha256:abc123" + assert tx.txn["document_hash"] == "sha256:abc123" def test_from_json(self): """Test TransactionData.from_json().""" @@ -205,7 +207,7 @@ def test_from_json(self): "credential_ids": ["default"], "nonce": "abc12345", "iat": 1771934400, - "txn": {"assetId": "test"}, + "txn": {"asset_id": "test"}, } ) @@ -246,7 +248,7 @@ def test_compute_hash_deterministic(self): credential_ids=["default"], nonce="da9b1009", iat=1771934400, - txn={"assetId": "test", "price": "100"}, + txn={"asset_id": "test", "price": "100"}, ) tx2 = TransactionData( @@ -254,7 +256,7 @@ def test_compute_hash_deterministic(self): credential_ids=["default"], nonce="da9b1009", iat=1771934400, - txn={"assetId": "test", "price": "100"}, + txn={"asset_id": "test", "price": "100"}, ) assert tx1.compute_hash() == tx2.compute_hash() @@ -266,7 +268,7 @@ def test_compute_hash_key_order_independent(self): credential_ids=["default"], nonce="da9b1009", iat=1771934400, - txn={"assetId": "test", "price": "100"}, + txn={"asset_id": "test", "price": "100"}, ) tx2 = TransactionData( @@ -274,7 +276,7 @@ def test_compute_hash_key_order_independent(self): credential_ids=["default"], nonce="da9b1009", iat=1771934400, - txn={"price": "100", "assetId": "test"}, # Different order + txn={"price": "100", "asset_id": "test"}, # Different order ) # Hashes should be equal since canonical JSON sorts keys @@ -284,7 +286,7 @@ def test_compute_hash_64_hex_chars(self): """Test that hash is 64 hex characters (SHA-256).""" tx = TransactionData.create( action="data.purchase", - txn={"assetId": "test"}, + txn={"asset_id": "test"}, ) hash_value = tx.compute_hash() @@ -299,7 +301,7 @@ def test_compute_hash_changes_with_data(self): credential_ids=["default"], nonce="da9b1009", iat=1771934400, - txn={"assetId": "test", "price": "100"}, + txn={"asset_id": "test", "price": "100"}, ) tx2 = TransactionData( @@ -307,7 +309,7 @@ def test_compute_hash_changes_with_data(self): credential_ids=["default"], nonce="da9b1009", iat=1771934400, - txn={"assetId": "test", "price": "200"}, # Different price + txn={"asset_id": "test", "price": "200"}, # Different price ) assert tx1.compute_hash() != tx2.compute_hash() @@ -319,7 +321,7 @@ def test_compute_hash_sensitive_to_all_fields(self): "credential_ids": ["default"], "nonce": "da9b1009", "iat": 1771934400, - "txn": {"assetId": "test"}, + "txn": {"asset_id": "test"}, } base_tx = TransactionData(**base) @@ -331,7 +333,7 @@ def test_compute_hash_sensitive_to_all_fields(self): {"credential_ids": ["other"]}, {"nonce": "different"}, {"iat": 9999999999}, - {"txn": {"assetId": "other"}}, + {"txn": {"asset_id": "other"}}, ] for change in variations: @@ -384,6 +386,28 @@ def test_challenge_matches(self, vectors): f" expected: {v['challenge']}" ) + def test_transaction_data_param_matches(self, vectors): + """Test base64url-encoded transaction_data request strings.""" + for v in vectors: + tx = TransactionData.from_dict(v["input"]) + encoded = encode_transaction_data_param(tx) + assert encoded == v["transaction_data_param"], ( + f"transaction_data param mismatch for '{v['name']}':\n" + f" got: {encoded}\n" + f" expected: {v['transaction_data_param']}" + ) + + def test_transaction_data_param_hash_matches(self, vectors): + """Test OID4VP transaction_data_hashes values.""" + for v in vectors: + tx = TransactionData.from_dict(v["input"]) + hash_value = compute_transaction_data_param_hash(tx) + assert hash_value == v["transaction_data_param_hash"], ( + f"transaction_data_hashes mismatch for '{v['name']}':\n" + f" got: {hash_value}\n" + f" expected: {v['transaction_data_param_hash']}" + ) + # ============================================================================= # Challenge Creation Tests @@ -400,7 +424,7 @@ def test_basic_challenge(self): credential_ids=["default"], nonce="da9b1009", iat=1771934400, - txn={"assetId": "test", "price": "100"}, + txn={"asset_id": "test", "price": "100"}, ) challenge = create_delegation_challenge(tx) @@ -418,7 +442,7 @@ def test_challenge_matches_hash(self): credential_ids=["default"], nonce="da9b1009", iat=1771934400, - txn={"assetId": "test"}, + txn={"asset_id": "test"}, ) challenge = create_delegation_challenge(tx) @@ -483,7 +507,7 @@ def test_round_trip(self): """Test create -> parse round-trip.""" tx = TransactionData.create( action="data.purchase", - txn={"assetId": "test"}, + txn={"asset_id": "test"}, ) challenge = create_delegation_challenge(tx) @@ -509,7 +533,7 @@ def test_verify_matching_challenge(self): credential_ids=["default"], nonce="da9b1009", iat=1771934400, - txn={"assetId": "test"}, + txn={"asset_id": "test"}, ) challenge = create_delegation_challenge(tx) @@ -523,7 +547,7 @@ def test_verify_mismatched_nonce(self): credential_ids=["default"], nonce="da9b1009", iat=1771934400, - txn={"assetId": "test"}, + txn={"asset_id": "test"}, ) # Create challenge with different nonce @@ -538,7 +562,7 @@ def test_verify_mismatched_hash(self): credential_ids=["default"], nonce="da9b1009", iat=1771934400, - txn={"assetId": "test"}, + txn={"asset_id": "test"}, ) # Create challenge with wrong hash @@ -553,7 +577,7 @@ def test_verify_tampered_data(self): credential_ids=["default"], nonce="da9b1009", iat=1771934400, - txn={"assetId": "test", "price": "100"}, + txn={"asset_id": "test", "price": "100"}, ) challenge = create_delegation_challenge(tx) @@ -576,7 +600,7 @@ def test_validate_valid_transaction(self): """Test validation of valid transaction.""" tx = TransactionData.create( action="data.purchase", - txn={"assetId": "test"}, + txn={"asset_id": "test"}, ) # Should not raise @@ -589,7 +613,7 @@ def test_validate_invalid_type(self): credential_ids=["default"], nonce="da9b1009", iat=int(time.time()), - txn={"assetId": "test"}, + txn={"asset_id": "test"}, ) with pytest.raises(ChallengeError) as excinfo: @@ -604,7 +628,7 @@ def test_validate_short_nonce(self): credential_ids=["default"], nonce="abc", # Too short (< 8 chars) iat=int(time.time()), - txn={"assetId": "test"}, + txn={"asset_id": "test"}, ) with pytest.raises(ChallengeError) as excinfo: @@ -620,7 +644,7 @@ def test_validate_old_timestamp(self): credential_ids=["default"], nonce="da9b1009", iat=old_iat, - txn={"assetId": "test"}, + txn={"asset_id": "test"}, ) with pytest.raises(ChallengeError) as excinfo: @@ -636,7 +660,7 @@ def test_validate_future_timestamp(self): credential_ids=["default"], nonce="da9b1009", iat=future_iat, - txn={"assetId": "test"}, + txn={"asset_id": "test"}, ) with pytest.raises(ChallengeError) as excinfo: @@ -649,7 +673,7 @@ def test_validate_expired_transaction(self): now = int(time.time()) tx = TransactionData.create( action="data.purchase", - txn={"assetId": "test"}, + txn={"asset_id": "test"}, exp=now - 300, # Expired 5 minutes ago ) @@ -667,7 +691,7 @@ def test_validate_custom_max_age(self): credential_ids=["default"], nonce="da9b1009", iat=old_iat, - txn={"assetId": "test"}, + txn={"asset_id": "test"}, ) # Should fail with 60s max age @@ -694,7 +718,7 @@ def test_render_basic(self): nonce="da9b1009", iat=1771934400, txn={ - "assetId": "urn:uuid:test", + "asset_id": "urn:uuid:test", "price": "100", "currency": "ENVITED", }, @@ -711,7 +735,7 @@ def test_render_custom_service_name(self): """Test display with custom service name.""" tx = TransactionData.create( action="data.purchase", - txn={"assetId": "test"}, + txn={"asset_id": "test"}, ) display = render_transaction_display(tx, service_name="Custom Service") @@ -737,7 +761,7 @@ def test_render_with_expiration(self): """Test display includes expiration if present.""" tx = TransactionData.create( action="data.purchase", - txn={"assetId": "test"}, + txn={"asset_id": "test"}, exp=1771935300, ) @@ -750,7 +774,7 @@ def test_render_with_description(self): """Test display includes description if present.""" tx = TransactionData.create( action="data.purchase", - txn={"assetId": "test"}, + txn={"asset_id": "test"}, description="Purchase sensor data from BMW", ) @@ -763,7 +787,7 @@ def test_render_truncates_long_values(self): """Test display truncates very long values.""" tx = TransactionData.create( action="data.purchase", - txn={"assetId": "a" * 100}, # Very long value + txn={"asset_id": "a" * 100}, # Very long value ) display = render_transaction_display(tx) @@ -874,7 +898,7 @@ def test_full_workflow(self): tx = TransactionData.create( action="data.purchase", txn={ - "assetId": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", }, @@ -906,7 +930,7 @@ def test_serialization_workflow(self): # Create and serialize original_tx = TransactionData.create( action="contract.sign", - txn={"documentHash": "sha256:abc123"}, + txn={"document_hash": "sha256:abc123"}, ) challenge = create_delegation_challenge(original_tx) tx_json = original_tx.to_json() @@ -924,7 +948,7 @@ def test_multiple_transactions_unique_hashes(self): for i in range(10): tx = TransactionData.create( action="data.purchase", - txn={"assetId": f"asset-{i}"}, + txn={"asset_id": f"asset-{i}"}, ) hashes.add(tx.compute_hash()) diff --git a/tests/python/harbour/test_sd_jwt_vp.py b/tests/python/harbour/test_sd_jwt_vp.py index a737793..8213499 100644 --- a/tests/python/harbour/test_sd_jwt_vp.py +++ b/tests/python/harbour/test_sd_jwt_vp.py @@ -5,10 +5,40 @@ import secrets import pytest +from harbour._crypto import import_private_key as _import_private_key +from harbour.delegation import ( + TransactionData, + compute_transaction_data_param_hash, + create_delegation_challenge, +) from harbour.keys import generate_p256_keypair, p256_public_key_to_did_key from harbour.sd_jwt import issue_sd_jwt_vc from harbour.sd_jwt_vp import issue_sd_jwt_vp, verify_sd_jwt_vp from harbour.verifier import VerificationError +from joserfc import jws + + +def _decode_jwt_payload(token: str) -> dict: + """Decode JWT payload without verifying signature.""" + payload_b64 = token.split(".")[1] + payload = base64.urlsafe_b64decode(payload_b64 + "=" * (-len(payload_b64) % 4)) + return json.loads(payload) + + +def _decode_jwt_header(token: str) -> dict: + """Decode JWT header without verifying signature.""" + header_b64 = token.split(".")[0] + header = base64.urlsafe_b64decode(header_b64 + "=" * (-len(header_b64) % 4)) + return json.loads(header) + + +def _resign_jwt(token: str, payload: dict, private_key) -> str: + """Re-sign a JWT payload with the original protected header.""" + header = _decode_jwt_header(token) + alg = header["alg"] + key = _import_private_key(private_key, alg) + payload_bytes = json.dumps(payload, ensure_ascii=False).encode("utf-8") + return jws.serialize_compact(header, payload_bytes, key, algorithms=[alg]) @pytest.fixture @@ -122,18 +152,20 @@ def test_issue_with_no_disclosures(self, sample_sd_jwt_vc, holder_keypair): def test_issue_with_evidence(self, sample_sd_jwt_vc, holder_keypair): """Test VP issuance with DelegatedSignatureEvidence.""" holder_private, _ = holder_keypair + tx_nonce = "tx-consent-nonce" + audience = "did:web:signing-service.example.com" evidence = [ { "type": "DelegatedSignatureEvidence", - "transactionData": { + "transaction_data": { "type": "harbour_delegate:data.purchase", "credential_ids": ["simpulse_id"], - "nonce": secrets.token_urlsafe(16), + "nonce": tx_nonce, "iat": 1771934400, - "txn": {"assetId": "tx:abc123", "price": "100"}, + "txn": {"asset_id": "tx:abc123", "price": "100"}, }, - "delegatedTo": "did:web:signing-service.example.com", + "delegatedTo": audience, } ] @@ -141,22 +173,52 @@ def test_issue_with_evidence(self, sample_sd_jwt_vc, holder_keypair): sample_sd_jwt_vc, holder_private, evidence=evidence, - nonce="tx-consent-nonce", - audience="did:web:signing-service.example.com", + nonce=tx_nonce, + audience=audience, ) - # Parse VP JWT payload to check evidence + # Parse VP/KB payloads to check evidence-derived bindings parts = vp.split("~") vp_jwt = parts[0] - payload_b64 = vp_jwt.split(".")[1] - payload = json.loads( - base64.urlsafe_b64decode(payload_b64 + "=" * (-len(payload_b64) % 4)) - ) + kb_jwt = parts[-1] + vp_payload = _decode_jwt_payload(vp_jwt) + kb_payload = _decode_jwt_payload(kb_jwt) + + expected_tx = TransactionData.from_dict(evidence[0]["transaction_data"]) + expected_challenge = create_delegation_challenge(expected_tx) + expected_hash = compute_transaction_data_param_hash(expected_tx) + + assert "vp" in vp_payload + assert "evidence" in vp_payload["vp"] + assert len(vp_payload["vp"]["evidence"]) == 1 + assert vp_payload["vp"]["evidence"][0]["type"] == "DelegatedSignatureEvidence" + assert vp_payload["vp"]["evidence"][0]["challenge"] == expected_challenge + assert vp_payload["nonce"] == tx_nonce + assert vp_payload["aud"] == audience + assert kb_payload["transaction_data_hashes"] == [expected_hash] + assert kb_payload["transaction_data_hashes_alg"] == "sha-256" + + def test_issue_keeps_transaction_data_field(self, sample_sd_jwt_vc, holder_keypair): + """Issue keeps delegated evidence transaction_data unchanged.""" + holder_private, _ = holder_keypair + evidence = [ + { + "type": "DelegatedSignatureEvidence", + "transaction_data": { + "type": "harbour_delegate:data.purchase", + "credential_ids": ["default"], + "nonce": "snake-nonce", + "iat": 1771934400, + "txn": {"asset_id": "tx:snake"}, + }, + } + ] - assert "vp" in payload - assert "evidence" in payload["vp"] - assert len(payload["vp"]["evidence"]) == 1 - assert payload["vp"]["evidence"][0]["type"] == "DelegatedSignatureEvidence" + vp = issue_sd_jwt_vp(sample_sd_jwt_vc, holder_private, evidence=evidence) + vp_payload = _decode_jwt_payload(vp.split("~")[0]) + delegated = vp_payload["vp"]["evidence"][0] + assert "transaction_data" in delegated + assert delegated["transaction_data"]["nonce"] == "snake-nonce" def test_issue_with_holder_did(self, sample_sd_jwt_vc, holder_keypair): """Test VP issuance with holder DID.""" @@ -267,7 +329,7 @@ def test_verify_evidence(self, sample_sd_jwt_vc, issuer_keypair, holder_keypair) evidence = [ { "type": "DelegatedSignatureEvidence", - "transactionData": { + "transaction_data": { "type": "harbour_delegate:blockchain.approve", "credential_ids": ["default"], "nonce": "unique-consent-nonce", @@ -289,6 +351,94 @@ def test_verify_evidence(self, sample_sd_jwt_vc, issuer_keypair, holder_keypair) assert len(result["evidence"]) == 1 assert result["evidence"][0]["type"] == "DelegatedSignatureEvidence" + def test_verify_fails_transaction_hash_mismatch( + self, sample_sd_jwt_vc, issuer_keypair, holder_keypair + ): + """Verification fails if KB transaction_data_hashes is tampered.""" + _, issuer_public = issuer_keypair + holder_private, holder_public = holder_keypair + nonce = "tx-hash-nonce" + + evidence = [ + { + "type": "DelegatedSignatureEvidence", + "transaction_data": { + "type": "harbour_delegate:data.purchase", + "credential_ids": ["default"], + "nonce": nonce, + "iat": 1771934400, + "txn": {"asset_id": "tx:abc123", "price": "100"}, + }, + } + ] + + vp = issue_sd_jwt_vp( + sample_sd_jwt_vc, holder_private, evidence=evidence, nonce=nonce + ) + parts = vp.split("~") + kb_payload = _decode_jwt_payload(parts[-1]) + kb_payload["transaction_data_hashes"] = ["00" * 32] + tampered_kb_jwt = _resign_jwt(parts[-1], kb_payload, holder_private) + tampered_vp = "~".join(parts[:-1] + [tampered_kb_jwt]) + + with pytest.raises(VerificationError, match="transaction_data_hashes mismatch"): + verify_sd_jwt_vp(tampered_vp, issuer_public, holder_public) + + def test_verify_fails_internal_audience_mismatch( + self, sample_sd_jwt_vc, issuer_keypair, holder_keypair + ): + """Verification fails when VP and KB-JWT audiences differ.""" + _, issuer_public = issuer_keypair + holder_private, holder_public = holder_keypair + + vp = issue_sd_jwt_vp( + sample_sd_jwt_vc, + holder_private, + nonce="aud-nonce", + audience="did:web:signing-service.example.com", + ) + parts = vp.split("~") + kb_payload = _decode_jwt_payload(parts[-1]) + kb_payload["aud"] = "did:web:evil.example.com" + tampered_kb_jwt = _resign_jwt(parts[-1], kb_payload, holder_private) + tampered_vp = "~".join(parts[:-1] + [tampered_kb_jwt]) + + with pytest.raises( + VerificationError, match="Audience mismatch between VP and KB-JWT" + ): + verify_sd_jwt_vp(tampered_vp, issuer_public, holder_public) + + def test_verify_fails_when_transaction_data_missing( + self, sample_sd_jwt_vc, issuer_keypair, holder_keypair + ): + """Verification fails when delegated evidence omits transaction_data.""" + _, issuer_public = issuer_keypair + holder_private, holder_public = holder_keypair + nonce = "snake-verify-nonce" + + evidence = [ + { + "type": "DelegatedSignatureEvidence", + "transaction_data": { + "type": "harbour_delegate:data.purchase", + "credential_ids": ["default"], + "nonce": nonce, + "iat": 1771934400, + "txn": {"asset_id": "tx:snake-verify"}, + }, + } + ] + vp = issue_sd_jwt_vp(sample_sd_jwt_vc, holder_private, evidence=evidence) + parts = vp.split("~") + vp_payload = _decode_jwt_payload(parts[0]) + delegated = vp_payload["vp"]["evidence"][0] + del delegated["transaction_data"] + tampered_vp_jwt = _resign_jwt(parts[0], vp_payload, holder_private) + tampered_vp = "~".join([tampered_vp_jwt] + parts[1:]) + + with pytest.raises(VerificationError, match="requires transaction_data"): + verify_sd_jwt_vp(tampered_vp, issuer_public, holder_public) + def test_verify_fails_wrong_issuer_key(self, sample_sd_jwt_vc, holder_keypair): """Test that verification fails with wrong issuer key.""" holder_private, holder_public = holder_keypair @@ -398,7 +548,7 @@ def test_delegated_consent_flow(self, issuer_keypair, holder_keypair): "iat": 1771934400, "description": "Purchase data asset XYZ for 100 ENVITED tokens", "txn": { - "assetId": "tx:0xabc123def456", + "asset_id": "tx:0xabc123def456", "price": "100", "currency": "ENVITED", }, @@ -407,12 +557,12 @@ def test_delegated_consent_flow(self, issuer_keypair, holder_keypair): evidence = [ { "type": "DelegatedSignatureEvidence", - "transactionData": transaction_data, + "transaction_data": transaction_data, "delegatedTo": signing_service_did, } ] - challenge_nonce = secrets.token_urlsafe(16) + challenge_nonce = consent_nonce # Create VP with: # - Only organization and role disclosed (not PII) @@ -451,8 +601,11 @@ def test_delegated_consent_flow(self, issuer_keypair, holder_keypair): assert len(result["evidence"]) == 1 ev = result["evidence"][0] assert ev["type"] == "DelegatedSignatureEvidence" - assert ev["transactionData"]["type"] == "harbour_delegate:data.purchase" - assert ev["transactionData"]["nonce"] == consent_nonce + assert ev["transaction_data"]["type"] == "harbour_delegate:data.purchase" + assert ev["transaction_data"]["nonce"] == consent_nonce + assert ev["challenge"] == create_delegation_challenge( + TransactionData.from_dict(transaction_data) + ) assert ev["delegatedTo"] == signing_service_did def test_public_audit_privacy(self, issuer_keypair, holder_keypair): @@ -481,7 +634,7 @@ def test_public_audit_privacy(self, issuer_keypair, holder_keypair): evidence = [ { "type": "DelegatedSignatureEvidence", - "transactionData": { + "transaction_data": { "type": "harbour_delegate:blockchain.transfer", "credential_ids": ["default"], "nonce": "public-audit-nonce", @@ -559,7 +712,16 @@ def test_multiple_evidence_items(self, sample_sd_jwt_vc, holder_keypair): holder_private, _ = holder_keypair evidence = [ - {"type": "DelegatedSignatureEvidence", "transactionData": {}}, + { + "type": "DelegatedSignatureEvidence", + "transaction_data": { + "type": "harbour_delegate:data.share", + "credential_ids": ["default"], + "nonce": "multi-evidence-nonce", + "iat": 1771934400, + "txn": {"resource_id": "asset:xyz"}, + }, + }, {"type": "CredentialEvidence", "verifiablePresentation": "eyJ..."}, ] diff --git a/tests/typescript/harbour/delegation.test.ts b/tests/typescript/harbour/delegation.test.ts index 0681eb4..d5250a6 100644 --- a/tests/typescript/harbour/delegation.test.ts +++ b/tests/typescript/harbour/delegation.test.ts @@ -21,9 +21,11 @@ import { TYPE_PREFIX, ChallengeError, type TransactionData, + computeTransactionDataParamHash, computeTransactionHash, createDelegationChallenge, createTransactionData, + encodeTransactionDataParam, getAction, parseDelegationChallenge, renderTransactionDisplay, @@ -42,12 +44,12 @@ describe("TransactionData", () => { it("creates basic transaction data", () => { const tx = createTransactionData({ action: "data.purchase", - txn: { assetId: "urn:uuid:test", price: "100" }, + txn: { asset_id: "urn:uuid:test", price: "100" }, }); expect(tx.type).toBe("harbour_delegate:data.purchase"); expect(tx.credential_ids).toEqual(["default"]); - expect(tx.txn).toEqual({ assetId: "urn:uuid:test", price: "100" }); + expect(tx.txn).toEqual({ asset_id: "urn:uuid:test", price: "100" }); expect(tx.exp).toBeUndefined(); expect(tx.description).toBeUndefined(); expect(tx.transaction_data_hashes_alg).toEqual(["sha-256"]); @@ -58,7 +60,7 @@ describe("TransactionData", () => { it("creates with custom nonce", () => { const tx = createTransactionData({ action: "data.purchase", - txn: { assetId: "test" }, + txn: { asset_id: "test" }, nonce: "custom123", }); expect(tx.nonce).toBe("custom123"); @@ -67,7 +69,7 @@ describe("TransactionData", () => { it("creates with custom iat", () => { const tx = createTransactionData({ action: "data.purchase", - txn: { assetId: "test" }, + txn: { asset_id: "test" }, iat: 1771934400, }); expect(tx.iat).toBe(1771934400); @@ -76,7 +78,7 @@ describe("TransactionData", () => { it("creates with optional fields", () => { const tx = createTransactionData({ action: "data.purchase", - txn: { assetId: "test" }, + txn: { asset_id: "test" }, exp: 1771935300, description: "Test purchase", credentialIds: ["simpulse_id"], @@ -90,7 +92,7 @@ describe("TransactionData", () => { it("extracts action from type", () => { const tx = createTransactionData({ action: "data.purchase", - txn: { assetId: "test" }, + txn: { asset_id: "test" }, }); expect(getAction(tx)).toBe("data.purchase"); }); @@ -127,7 +129,7 @@ describe("Canonical JSON", () => { credential_ids: ["default"], nonce: "da9b1009", iat: 1771934400, - txn: { assetId: "test", price: "100" }, + txn: { asset_id: "test", price: "100" }, transaction_data_hashes_alg: ["sha-256"], }; @@ -147,7 +149,7 @@ describe("Canonical JSON", () => { credential_ids: ["default"], nonce: "da9b1009", iat: 1771934400, - txn: { assetId: "test", price: "100" }, + txn: { asset_id: "test", price: "100" }, transaction_data_hashes_alg: ["sha-256"], }; @@ -156,7 +158,7 @@ describe("Canonical JSON", () => { credential_ids: ["default"], nonce: "da9b1009", iat: 1771934400, - txn: { price: "100", assetId: "test" }, + txn: { price: "100", asset_id: "test" }, transaction_data_hashes_alg: ["sha-256"], }; @@ -171,13 +173,13 @@ describe("Canonical JSON", () => { credential_ids: ["default"], nonce: "da9b1009", iat: 1771934400, - txn: { assetId: "test", price: "100" }, + txn: { asset_id: "test", price: "100" }, transaction_data_hashes_alg: ["sha-256"], }; const tx2: TransactionData = { ...tx1, - txn: { assetId: "test", price: "200" }, + txn: { asset_id: "test", price: "200" }, }; expect(await computeTransactionHash(tx1)).not.toBe( @@ -215,6 +217,18 @@ describe("Shared canonicalization vectors", () => { const challenge = await createDelegationChallenge(td); expect(challenge).toBe(v.challenge); }); + + it(`transaction_data param encoding matches for '${v.name}'`, () => { + const td = v.input as TransactionData; + const encoded = encodeTransactionDataParam(td); + expect(encoded).toBe(v.transaction_data_param); + }); + + it(`transaction_data_hashes value matches for '${v.name}'`, async () => { + const td = v.input as TransactionData; + const hash = await computeTransactionDataParamHash(td); + expect(hash).toBe(v.transaction_data_param_hash); + }); } }); @@ -229,7 +243,7 @@ describe("createDelegationChallenge", () => { credential_ids: ["default"], nonce: "da9b1009", iat: 1771934400, - txn: { assetId: "test", price: "100" }, + txn: { asset_id: "test", price: "100" }, transaction_data_hashes_alg: ["sha-256"], }; @@ -278,7 +292,7 @@ describe("parseDelegationChallenge", () => { it("round-trips with createDelegationChallenge", async () => { const tx = createTransactionData({ action: "data.purchase", - txn: { assetId: "test" }, + txn: { asset_id: "test" }, }); const challenge = await createDelegationChallenge(tx); @@ -301,7 +315,7 @@ describe("verifyChallenge", () => { credential_ids: ["default"], nonce: "da9b1009", iat: 1771934400, - txn: { assetId: "test" }, + txn: { asset_id: "test" }, transaction_data_hashes_alg: ["sha-256"], }; @@ -315,7 +329,7 @@ describe("verifyChallenge", () => { credential_ids: ["default"], nonce: "da9b1009", iat: 1771934400, - txn: { assetId: "test" }, + txn: { asset_id: "test" }, transaction_data_hashes_alg: ["sha-256"], }; @@ -330,7 +344,7 @@ describe("verifyChallenge", () => { credential_ids: ["default"], nonce: "da9b1009", iat: 1771934400, - txn: { assetId: "test" }, + txn: { asset_id: "test" }, transaction_data_hashes_alg: ["sha-256"], }; @@ -347,7 +361,7 @@ describe("validateTransactionData", () => { it("validates a valid transaction", () => { const tx = createTransactionData({ action: "data.purchase", - txn: { assetId: "test" }, + txn: { asset_id: "test" }, }); expect(() => validateTransactionData(tx)).not.toThrow(); }); @@ -358,7 +372,7 @@ describe("validateTransactionData", () => { credential_ids: ["default"], nonce: "da9b1009", iat: Math.floor(Date.now() / 1000), - txn: { assetId: "test" }, + txn: { asset_id: "test" }, transaction_data_hashes_alg: ["sha-256"], }; expect(() => validateTransactionData(tx)).toThrow(ChallengeError); @@ -370,7 +384,7 @@ describe("validateTransactionData", () => { credential_ids: ["default"], nonce: "abc", iat: Math.floor(Date.now() / 1000), - txn: { assetId: "test" }, + txn: { asset_id: "test" }, transaction_data_hashes_alg: ["sha-256"], }; expect(() => validateTransactionData(tx)).toThrow(/Nonce too short/); @@ -382,7 +396,7 @@ describe("validateTransactionData", () => { credential_ids: ["default"], nonce: "da9b1009", iat: Math.floor(Date.now() / 1000) - 600, - txn: { assetId: "test" }, + txn: { asset_id: "test" }, transaction_data_hashes_alg: ["sha-256"], }; expect(() => validateTransactionData(tx, { maxAgeSeconds: 300 })).toThrow( @@ -396,7 +410,7 @@ describe("validateTransactionData", () => { credential_ids: ["default"], nonce: "da9b1009", iat: Math.floor(Date.now() / 1000) + 300, - txn: { assetId: "test" }, + txn: { asset_id: "test" }, transaction_data_hashes_alg: ["sha-256"], }; expect(() => validateTransactionData(tx)).toThrow(/future/); @@ -405,7 +419,7 @@ describe("validateTransactionData", () => { it("throws for expired transaction", () => { const tx = createTransactionData({ action: "data.purchase", - txn: { assetId: "test" }, + txn: { asset_id: "test" }, exp: Math.floor(Date.now() / 1000) - 300, }); expect(() => validateTransactionData(tx)).toThrow(/expired/); @@ -423,7 +437,7 @@ describe("renderTransactionDisplay", () => { credential_ids: ["default"], nonce: "da9b1009", iat: 1771934400, - txn: { assetId: "urn:uuid:test", price: "100", currency: "ENVITED" }, + txn: { asset_id: "urn:uuid:test", price: "100", currency: "ENVITED" }, transaction_data_hashes_alg: ["sha-256"], }; diff --git a/tests/typescript/harbour/kb-jwt.test.ts b/tests/typescript/harbour/kb-jwt.test.ts index 141cef8..29318da 100644 --- a/tests/typescript/harbour/kb-jwt.test.ts +++ b/tests/typescript/harbour/kb-jwt.test.ts @@ -64,14 +64,14 @@ describe("KB-JWT creation", () => { const withKb = await createKbJwt(sdJwt, holderPrivateKey, { nonce: "test-nonce", audience: "did:web:verifier.example.com", - transactionData: ["tx1", "tx2"], + transaction_data: ["tx1", "tx2"], }); // Verify and check payload const payload = await verifyKbJwt(withKb, holderPublicKey, { expectedNonce: "test-nonce", expectedAudience: "did:web:verifier.example.com", - expectedTransactionData: ["tx1", "tx2"], + expected_transaction_data: ["tx1", "tx2"], }); expect(payload.transaction_data_hashes).toBeDefined(); @@ -152,14 +152,14 @@ describe("KB-JWT verification", () => { const withKb = await createKbJwt(sdJwt, holderPrivateKey, { nonce: "nonce", audience: "aud", - transactionData: ["tx1", "tx2"], + transaction_data: ["tx1", "tx2"], }); await expect( verifyKbJwt(withKb, holderPublicKey, { expectedNonce: "nonce", expectedAudience: "aud", - expectedTransactionData: ["tx1", "WRONG"], + expected_transaction_data: ["tx1", "WRONG"], }) ).rejects.toThrow(KbJwtVerificationError); }); diff --git a/tests/typescript/harbour/sd-jwt-vp.test.ts b/tests/typescript/harbour/sd-jwt-vp.test.ts index 6db76e7..5c853ab 100644 --- a/tests/typescript/harbour/sd-jwt-vp.test.ts +++ b/tests/typescript/harbour/sd-jwt-vp.test.ts @@ -3,11 +3,16 @@ */ import { describe, expect, it, beforeAll } from "vitest"; +import { SignJWT, type JWTPayload } from "jose"; import { generateP256Keypair, p256PublicKeyToDidKey, } from "../../../src/typescript/harbour/keys.js"; +import { + computeTransactionDataParamHash, + createDelegationChallenge, +} from "../../../src/typescript/harbour/delegation.js"; import { issueSdJwtVc } from "../../../src/typescript/harbour/sd-jwt.js"; import { issueSdJwtVp, @@ -23,6 +28,29 @@ let holderPublic: CryptoKey; let holderDid: string; let sampleSdJwtVc: string; +function decodeJwtPayload(token: string): any { + return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString()); +} + +function decodeJwtHeader(token: string): any { + return JSON.parse(Buffer.from(token.split(".")[0], "base64url").toString()); +} + +async function resignJwt( + token: string, + payload: Record, + privateKey: CryptoKey +): Promise { + const header = decodeJwtHeader(token); + const protectedHeader: { alg: string; typ?: string } = { + alg: String(header.alg), + ...(typeof header.typ === "string" ? { typ: header.typ } : {}), + }; + return new SignJWT(payload as unknown as JWTPayload) + .setProtectedHeader(protectedHeader) + .sign(privateKey); +} + beforeAll(async () => { const issuerKp = await generateP256Keypair(); issuerPrivate = issuerKp.privateKey; @@ -92,36 +120,69 @@ describe("issueSdJwtVp", () => { }); it("issues with evidence", async () => { + const txNonce = "tx-consent-nonce"; + const audience = "did:web:signing-service.example.com"; const evidence = [ { type: "DelegatedSignatureEvidence", - transactionData: { + transaction_data: { type: "harbour_delegate:data.purchase", credential_ids: ["simpulse_id"], - nonce: "tx-nonce", + nonce: txNonce, iat: 1771934400, - txn: { assetId: "tx:abc123", price: "100" }, + txn: { asset_id: "tx:abc123", price: "100" }, }, - delegatedTo: "did:web:signing-service.example.com", + delegatedTo: audience, }, ]; const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { evidence, - nonce: "tx-consent-nonce", - audience: "did:web:signing-service.example.com", + nonce: txNonce, + audience, }); - // Parse VP payload to check evidence + // Parse VP/KB payloads to check evidence-derived bindings const parts = vp.split("~"); - const vpPayload = JSON.parse( - Buffer.from(parts[0].split(".")[1], "base64url").toString() + const vpPayload = decodeJwtPayload(parts[0]); + const kbPayload = decodeJwtPayload(parts[parts.length - 1]); + const expectedChallenge = await createDelegationChallenge( + evidence[0].transaction_data + ); + const expectedHash = await computeTransactionDataParamHash( + evidence[0].transaction_data ); expect(vpPayload.vp.evidence).toHaveLength(1); expect(vpPayload.vp.evidence[0].type).toBe( "DelegatedSignatureEvidence" ); + expect(vpPayload.vp.evidence[0].challenge).toBe(expectedChallenge); + expect(vpPayload.nonce).toBe(txNonce); + expect(vpPayload.aud).toBe(audience); + expect(kbPayload.transaction_data_hashes).toEqual([expectedHash]); + expect(kbPayload.transaction_data_hashes_alg).toBe("sha-256"); + }); + + it("keeps delegated evidence transaction_data unchanged", async () => { + const evidence = [ + { + type: "DelegatedSignatureEvidence", + transaction_data: { + type: "harbour_delegate:data.purchase", + credential_ids: ["default"], + nonce: "snake-nonce", + iat: 1771934400, + txn: { asset_id: "tx:snake" }, + }, + }, + ]; + + const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { evidence }); + const vpPayload = decodeJwtPayload(vp.split("~")[0]); + const delegated = vpPayload.vp.evidence[0]; + expect(delegated.transaction_data).toBeDefined(); + expect(delegated.transaction_data.nonce).toBe("snake-nonce"); }); it("issues with holder DID", async () => { @@ -193,7 +254,7 @@ describe("verifySdJwtVp", () => { const evidence = [ { type: "DelegatedSignatureEvidence", - transactionData: { + transaction_data: { type: "harbour_delegate:blockchain.approve", credential_ids: ["default"], nonce: "consent-nonce", @@ -210,6 +271,89 @@ describe("verifySdJwtVp", () => { expect(result.evidence![0].type).toBe("DelegatedSignatureEvidence"); }); + it("fails when transaction_data_hashes is tampered", async () => { + const nonce = "tx-hash-nonce"; + const evidence = [ + { + type: "DelegatedSignatureEvidence", + transaction_data: { + type: "harbour_delegate:data.purchase", + credential_ids: ["default"], + nonce, + iat: 1771934400, + txn: { asset_id: "tx:abc123", price: "100" }, + }, + }, + ]; + + const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { + evidence, + nonce, + }); + const parts = vp.split("~"); + const kbPayload = decodeJwtPayload(parts[parts.length - 1]); + kbPayload.transaction_data_hashes = ["00".repeat(32)]; + const tamperedKbJwt = await resignJwt( + parts[parts.length - 1], + kbPayload, + holderPrivate + ); + const tamperedVp = [...parts.slice(0, -1), tamperedKbJwt].join("~"); + + await expect( + verifySdJwtVp(tamperedVp, issuerPublic, holderPublic) + ).rejects.toThrow(/transaction_data_hashes mismatch/); + }); + + it("fails when VP and KB audiences differ", async () => { + const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { + nonce: "aud-nonce", + audience: "did:web:signing-service.example.com", + }); + const parts = vp.split("~"); + const kbPayload = decodeJwtPayload(parts[parts.length - 1]); + kbPayload.aud = "did:web:evil.example.com"; + const tamperedKbJwt = await resignJwt( + parts[parts.length - 1], + kbPayload, + holderPrivate + ); + const tamperedVp = [...parts.slice(0, -1), tamperedKbJwt].join("~"); + + await expect( + verifySdJwtVp(tamperedVp, issuerPublic, holderPublic) + ).rejects.toThrow(/Audience mismatch between VP and KB-JWT/); + }); + + it("fails when delegated evidence omits transaction_data", async () => { + const nonce = "snake-verify-nonce"; + const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { + evidence: [ + { + type: "DelegatedSignatureEvidence", + transaction_data: { + type: "harbour_delegate:data.purchase", + credential_ids: ["default"], + nonce, + iat: 1771934400, + txn: { asset_id: "tx:snake-verify" }, + }, + }, + ], + }); + + const parts = vp.split("~"); + const vpPayload = decodeJwtPayload(parts[0]); + const delegated = vpPayload.vp.evidence[0]; + delete delegated.transaction_data; + const tamperedVpJwt = await resignJwt(parts[0], vpPayload, holderPrivate); + const tamperedVp = [tamperedVpJwt, ...parts.slice(1)].join("~"); + + await expect( + verifySdJwtVp(tamperedVp, issuerPublic, holderPublic) + ).rejects.toThrow(/requires transaction_data/); + }); + it("fails with wrong issuer key", async () => { const wrongKp = await generateP256Keypair(); const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate); diff --git a/tests/validation-probe/ontology-loading-probe.json b/tests/validation-probe/ontology-loading-probe.json new file mode 100644 index 0000000..51af546 --- /dev/null +++ b/tests/validation-probe/ontology-loading-probe.json @@ -0,0 +1,12 @@ +{ + "@context": { + "@vocab": "https://w3id.org/reachhaven/harbour/credentials/v1/", + "gx": "https://w3id.org/gaia-x/development#" + }, + "@id": "urn:uuid:ontology-loading-probe", + "@type": [ + "https://w3id.org/reachhaven/harbour/credentials/v1/LoadProbe", + "https://w3id.org/reachhaven/harbour/core/v1/LoadProbe", + "https://w3id.org/gaia-x/development#LoadProbe" + ] +} From 3e3622ed6b3669842fbb4614faf0e0726dde269d Mon Sep 17 00:00:00 2001 From: jdsika Date: Thu, 26 Feb 2026 11:45:59 +0100 Subject: [PATCH 04/78] =?UTF-8?q?feat(harbour):=20refactor=20credential=20?= =?UTF-8?q?issuance=20model=20=E2=80=94=20Signing=20Service=20as=20sole=20?= =?UTF-8?q?issuer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the credential issuance model so the Harbour Signing Service is the sole issuer of ALL credentials (LegalPerson + NaturalPerson), acting "on behalf of" an authorizing party. Evidence VPs now contain authorization proof instead of empty key-ownership VPs. Signing Service DID document: - Add #key-2 (P-256) for capabilityDelegation (delegated txn signing) - #key-1 remains assertionMethod (credential issuance) - Both keys listed under authentication Trust Anchor DID document: - Add LinkedCredentialService endpoint pointing to self-signed credential - Self-signed LegalPersonCredential (issuer == subject, root of trust) Credential issuance chain: - LegalPersonCredential: Trust Anchor authorizes org via VP containing its self-signed credential → Signing Service issues with VP as evidence - NaturalPersonCredential: Org authorizes employee via VP containing its LegalPersonCredential (SD-JWT, PII redacted) → Signing Service issues with VP as evidence Delete Harbour Credential Issuer: - Remove harbour-credential-issuer.did.json (DID Eoc79uQyWN5vo9...) - Merge issuer role into Signing Service (DID Er9_mnFstIFyj7...) - Update all references in examples, docs, and tests LinkML schema: - Update CredentialEvidence description for authorization model - Add LinkedCredentialService class to ServiceUnion - Update gaiax-domain.yaml evidence comments for both credential types Remove consent-vp.json: - Redundant — the receipt credential embeds the consent VP as evidence - The consent VP is an ephemeral artifact, not a persisted example Fix vc+ld+jwt → vc+jwt in signer/verifier source code (both runtimes): - VC-JOSE-COSE spec: "No +ld+ media types exist" - signer.py/signer.ts: typ="vc+jwt" for VCs, typ="vp+jwt" for VPs - verifier.py/verifier.ts: expected_typ updated to match - All test assertions updated (sign, tamper, interop, example_signer) Clean up legacy test data: - Replace simpulse-id VCT URIs with Harbour namespace in SD-JWT and KB-JWT tests (Python + TypeScript) - Replace ASCS/BMW sample claims with generic example data - Fix did:web → did:webs in test_sd_jwt_vp.py Docs audit — fix legacy references across all specs and ADRs: - delegation-challenge-encoding.md: replace did:web with did:webs for Signing Service (5 occurrences), remove simpulse-id-credentials refs - did-method-evaluation.md: update from SimpulseID to Harbour, rewrite section 7 for current did:webs implementation - ADR-001: fix vc+ld+jwt → vc+jwt (VC-JOSE-COSE spec compliance), update did:web → did:webs for key resolution - ADR-004: update ASCS/BMW examples to Harbour entities, did:web → did:webs - architecture.md: update key resolution from did:web to did:webs - ADR-001, ADR-004: remove backwards-compatibility and "legacy" language (this is v1 — no compatibility concerns exist) Signed-off-by: jdsika --- README.md | 6 +- docs/architecture.md | 2 +- docs/decisions/001-vc-securing-mechanism.md | 16 +- docs/decisions/004-key-management.md | 14 +- docs/guide/delegated-signing.md | 55 +-- docs/guide/evidence.md | 55 +-- docs/specs/delegation-challenge-encoding.md | 71 ++- docs/specs/did-method-evaluation.md | 135 +++--- docs/specs/references/README.md | 10 +- docs/specs/references/csc-data-model.md | 47 ++ docs/specs/references/sd-jwt-vc.md | 58 +++ docs/specs/references/vc-jose-cose.md | 56 +++ examples/README.md | 404 ++++++++++++++++++ examples/consent-vp.json | 53 --- examples/delegated-signing-receipt.json | 18 +- examples/did-webs/README.md | 61 ++- .../did-webs/harbour-signing-service.did.json | 42 ++ .../did-webs/harbour-trust-anchor.did.json | 34 ++ ...6d7ea-27ef-416f-abf8-9cb634884e66.did.json | 27 ++ .../did-webs/legal-person-altme_sas.did.json | 27 -- .../did-webs/legal-person-bmw_ag.did.json | 27 -- ...e8400-e29b-41d4-a716-446655440000.did.json | 4 +- examples/legal-person-credential.json | 37 +- examples/natural-person-credential.json | 32 +- examples/service-offering-credential.json | 37 -- examples/trust-anchor-credential.json | 46 ++ linkml/core.yaml | 22 +- linkml/gaiax-domain.yaml | 22 + linkml/harbour.yaml | 117 ++++- src/python/credentials/claim_mapping.py | 2 +- src/python/harbour/kb_jwt.py | 18 +- src/python/harbour/sd_jwt_vp.py | 23 +- src/python/harbour/signer.py | 4 +- src/python/harbour/verifier.py | 4 +- src/typescript/harbour/kb-jwt.ts | 24 +- src/typescript/harbour/sd-jwt-vp.ts | 8 +- src/typescript/harbour/signer.ts | 4 +- src/typescript/harbour/verifier.ts | 4 +- tests/fixtures/canonicalization-vectors.json | 12 +- tests/fixtures/sample-vc.json | 4 +- tests/interop/test_cross_runtime.py | 4 +- .../python/credentials/test_claim_mapping.py | 49 +-- .../python/credentials/test_example_signer.py | 33 +- tests/python/harbour/test_delegation.py | 8 +- tests/python/harbour/test_kb_jwt.py | 8 +- tests/python/harbour/test_sd_jwt.py | 22 +- tests/python/harbour/test_sd_jwt_vp.py | 6 +- tests/python/harbour/test_sign.py | 6 +- tests/python/harbour/test_tamper.py | 2 +- tests/typescript/harbour/delegation.test.ts | 4 +- tests/typescript/harbour/sd-jwt-vp.test.ts | 2 +- tests/typescript/harbour/sd-jwt.test.ts | 16 +- tests/typescript/harbour/sign.test.ts | 4 +- tests/typescript/harbour/tamper.test.ts | 2 +- 54 files changed, 1239 insertions(+), 569 deletions(-) create mode 100644 docs/specs/references/csc-data-model.md create mode 100644 docs/specs/references/sd-jwt-vc.md create mode 100644 docs/specs/references/vc-jose-cose.md create mode 100644 examples/README.md delete mode 100644 examples/consent-vp.json create mode 100644 examples/did-webs/harbour-signing-service.did.json create mode 100644 examples/did-webs/harbour-trust-anchor.did.json create mode 100644 examples/did-webs/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json delete mode 100644 examples/did-webs/legal-person-altme_sas.did.json delete mode 100644 examples/did-webs/legal-person-bmw_ag.did.json delete mode 100644 examples/service-offering-credential.json create mode 100644 examples/trust-anchor-credential.json diff --git a/README.md b/README.md index f079673..ecb3ef2 100644 --- a/README.md +++ b/README.md @@ -124,10 +124,10 @@ The composition pattern keeps harbour properties on the harbour-typed outer node "https://w3id.org/reachhaven/harbour/credentials/v1/" ], "type": ["VerifiableCredential", "harbour:LegalPersonCredential"], - "issuer": "did:web:trust-anchor.example.com", + "issuer": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", "validFrom": "2024-01-15T00:00:00Z", "credentialSubject": { - "id": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", "type": "harbour:LegalPerson", "name": "Example Corporation GmbH", "gxParticipant": { @@ -146,7 +146,7 @@ The composition pattern keeps harbour properties on the harbour-typed outer node }, "credentialStatus": [ { - "id": "did:web:issuer.example.com:revocation#abc123", + "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo:services:revocation-registry#abc123", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } diff --git a/docs/architecture.md b/docs/architecture.md index 2b5208d..8759adc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,7 +34,7 @@ harbour-credentials/ |--------|--------| | Proof format | SD-JWT-VC + VC-JOSE-COSE | | Algorithm | ES256 (P-256) primary, EdDSA (Ed25519) supported | -| Key resolution | X.509 (x5c) + did:web + did:key | +| Key resolution | X.509 (x5c) + did:webs + did:key | | Selective disclosure | Native (SD-JWT-VC) | | Canonicalization | None needed (JWT/SD-JWT) | | Runtimes | Python + TypeScript | diff --git a/docs/decisions/001-vc-securing-mechanism.md b/docs/decisions/001-vc-securing-mechanism.md index ffa7371..e0d569a 100644 --- a/docs/decisions/001-vc-securing-mechanism.md +++ b/docs/decisions/001-vc-securing-mechanism.md @@ -34,7 +34,7 @@ W3C spec for embedding cryptographic proofs inside credential JSON. W3C spec for wrapping VC Data Model 2.0 credentials in JWT/JWS or COSE. -- **Format:** Standard JWT with `typ: vc+ld+jwt`, payload is the full VC JSON-LD +- **Format:** Standard JWT with `typ: vc+jwt`, payload is the full VC JSON-LD - **Data model:** W3C VC Data Model 2.0 (`@context`, `type` array, `credentialSubject`) - **Selective disclosure:** Supported via SD-JWT extension within VC-JOSE-COSE - **Libraries:** Any JOSE library (npm `jose`, Python `joserfc`) @@ -101,7 +101,7 @@ HAIP requires: > "The public key used to validate the signature MUST be included in the x5c JOSE header parameter" -Gaia-X uses DIDs (primarily `did:web`) plus X.509 via GXDCH. We need to support **both** `x5c` (for EUDI) and DID resolution (for Gaia-X). +Gaia-X uses DIDs (primarily `did:web`/`did:webs`) plus X.509 via GXDCH. We need to support **both** `x5c` (for EUDI) and DID resolution (for Gaia-X). Harbour uses `did:webs` for all identities. ### 3. SD-JWT-VC ≠ W3C VC Data Model @@ -137,7 +137,7 @@ Support **two complementary formats**, serving different purposes: |--------|--------| | Format | SD-JWT-VC (compact serialization) | | Algorithm | **ES256** (ECDSA P-256) — HAIP mandatory minimum | -| Key resolution | X.509 via `x5c` header (EUDI) + `did:web` (Gaia-X) | +| Key resolution | X.509 via `x5c` header (EUDI) + `did:webs` (Gaia-X) | | Selective disclosure | Native SD-JWT | | Holder binding | `cnf` claim with proof-of-possession | | Status | `status_list` (Token Status List) | @@ -151,9 +151,9 @@ Support **two complementary formats**, serving different purposes: |--------|--------| | Format | Compact JWS (`header.payload.signature`) | | Algorithm | **ES256** (consistent with SD-JWT-VC) | -| JWT header | `{"alg": "ES256", "typ": "vc+ld+jwt"}` | +| JWT header | `{"alg": "ES256", "typ": "vc+jwt"}` | | Payload | Full W3C VCDM 2.0 JSON-LD | -| Key resolution | `did:web` (Gaia-X) + `x5c` (EUDI alignment) | +| Key resolution | `did:webs` (Gaia-X) + `x5c` (EUDI alignment) | | JS library | npm `jose` | | Python library | `joserfc` | @@ -163,10 +163,10 @@ Support **two complementary formats**, serving different purposes: |--------|---------|--------| | Algorithm | Ed25519 (EdDSA) | **P-256 (ES256)** | | Key format | JWK OKP/Ed25519 | **JWK EC/P-256** | -| DID method | `did:key:z6Mk...` | `did:key:zDn...` (P-256) + `did:web` | +| DID method | `did:key:z6Mk...` | `did:key:zDn...` (P-256) + `did:webs` | | Certificates | None | X.509 chains via `x5c` | -Ed25519 keys SHOULD still be supported for backwards compatibility and testing, but **ES256 MUST be the default** for EUDI compliance. +Ed25519 is also supported for testing, but **ES256 MUST be the default** for EUDI compliance. ## Relationship Between Formats @@ -200,7 +200,7 @@ A credential can exist in multiple formats simultaneously. The mapping from SHAC ### Positive - EUDI wallet compatible (SD-JWT-VC + ES256 + x5c) -- Gaia-X compatible (VC-JWT + did:web) +- Gaia-X compatible (VC-JWT + did:webs) - Selective disclosure for privacy-sensitive fields - Both Python and JS implementations exist for SD-JWT-VC - Future-proof (SD-JWT-VC is the regulatory direction) diff --git a/docs/decisions/004-key-management.md b/docs/decisions/004-key-management.md index 1414a68..a4f9758 100644 --- a/docs/decisions/004-key-management.md +++ b/docs/decisions/004-key-management.md @@ -38,7 +38,7 @@ The original choice of Ed25519 must be revised based on regulatory requirements: | Key size | 64 bytes public | 32 bytes public | | X.509 support | Universal | Limited | | did:key prefix | `zDn...` | `z6Mk...` | -| Role in harbour | **Default** | Testing/legacy | +| Role in harbour | **Default** | Testing | ### Key Format: JWK (RFC 7517) @@ -52,7 +52,7 @@ The original choice of Ed25519 must be revised based on regulatory requirements: } ``` -**Ed25519 key (legacy):** +**Ed25519 key:** ```json { "kty": "OKP", @@ -61,14 +61,14 @@ The original choice of Ed25519 must be revised based on regulatory requirements: } ``` -### Key Resolution: X.509 (EUDI) + DID (Gaia-X) +### Key Resolution: X.509 (EUDI) + DID (Gaia-X / Harbour) Three mechanisms, serving different ecosystems: | Method | Ecosystem | JOSE Header | Example | |--------|-----------|-------------|---------| | **X.509 chain** | EUDI | `x5c` | Certificate chain in JWT header | -| **did:web** | Gaia-X | `kid` | `did:web:did.ascs.digital:participants:bmw#key-1` | +| **did:webs** | Gaia-X | `kid` | `did:webs:participants.harbour.reachhaven.com:legal-persons::#key-1` | | **did:key** | Testing | `kid` | `did:key:zDn...#zDn...` | **X.509 (EUDI mandatory):** @@ -77,10 +77,10 @@ Three mechanisms, serving different ecosystems: - Trust anchor certificate excluded from chain - No self-signed end-entity certificates -**did:web (Gaia-X):** -- Resolves to DID Document at well-known URL +**did:webs (Gaia-X):** +- Resolves to DID Document at well-known URL with KERI key history - DID Document contains JWK public key(s) -- Used for organizational identities (ASCS, BMW, etc.) +- Used for all Harbour identities (infrastructure, organizations, users) - Gaia-X GXDCH uses X.509 certificates as trust anchors for DIDs **did:key (testing):** diff --git a/docs/guide/delegated-signing.md b/docs/guide/delegated-signing.md index 7ccec9d..4a276ed 100644 --- a/docs/guide/delegated-signing.md +++ b/docs/guide/delegated-signing.md @@ -62,29 +62,30 @@ The user needs a Harbour credential (e.g., `NaturalPersonCredential`) issued as ```json { "type": ["VerifiableCredential", "harbour:NaturalPersonCredential"], - "issuer": "did:web:issuer.example.com", + "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", "credentialSubject": { - "id": "did:web:carlo.simpulse.io", + "id": "did:webs:users.altme.example:natural-persons:550e8400-...:EKYGGh-...", "type": "harbour:NaturalPerson", - "name": "Carlo Rossi", // ← Disclosable (PII) - "email": "carlo@bmw.de", // ← Disclosable (PII) - "memberOf": "did:web:bmw.gaiax.de" + "name": "Alice Smith", // ← Disclosable (PII) + "email": "alice.smith@example.com", // ← Disclosable (PII) + "memberOf": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-...:ENro7uf0eP..." } } ``` ### 2. DID Document -The user's DID document (`did:web:carlo.simpulse.io`) must contain a verification method with their P-256 public key: +The user's `did:webs` DID document must contain a verification method with their P-256 public key (the same key as in their `did:jwk` wallet): ```json { - "@context": ["https://www.w3.org/ns/did/v1"], - "id": "did:web:carlo.simpulse.io", + "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/jwk/v1"], + "id": "did:webs:users.altme.example:natural-persons:550e8400-...:EKYGGh-...", + "controller": "did:webs:users.altme.example:natural-persons:550e8400-...:EKYGGh-...", "verificationMethod": [{ - "id": "did:web:carlo.simpulse.io#key-1", + "id": "did:webs:users.altme.example:natural-persons:550e8400-...:EKYGGh-...#key-1", "type": "JsonWebKey2020", - "controller": "did:web:carlo.simpulse.io", + "controller": "did:webs:users.altme.example:natural-persons:550e8400-...:EKYGGh-...", "publicKeyJwk": { "kty": "EC", "crv": "P-256", @@ -92,10 +93,13 @@ The user's DID document (`did:web:carlo.simpulse.io`) must contain a verificatio "y": "..." } }], - "authentication": ["did:web:carlo.simpulse.io#key-1"] + "authentication": ["#key-1"], + "assertionMethod": ["#key-1"] } ``` +See [`examples/did-webs/`](../../examples/did-webs/) for complete DID documents. + ### Repository Boundary (did:web / did:webs) This repository verifies signatures and hash bindings, but it does **not** host or publish DID documents. @@ -104,8 +108,7 @@ This repository verifies signatures and hash bindings, but it does **not** host - Integrators must run DID resolution and pass the resolved holder key into `verify_sd_jwt_vp(...)`. - Repository examples now use `did:webs` identifiers for person subjects. See `examples/did-webs/` for static example DID documents used by `examples/*.json`. - Naming policy in examples: - - Natural persons use UUID-based path segments (no real names in DID path). - - Legal persons may use organization suffixes (for example `bmw_ag`). + - All identifiers use UUID-based path segments (no real names or organization names in DID paths). Current integration hooks and TODOs: @@ -120,7 +123,7 @@ The signing service creates an OID4VP-aligned transaction data object (see [Dele ```json { "type": "harbour_delegate:data.purchase", - "credential_ids": ["simpulse_id"], + "credential_ids": ["harbour_natural_person"], "transaction_data_hashes_alg": ["sha-256"], "nonce": "da9b1009", "iat": 1771934400, @@ -159,7 +162,7 @@ evidence = [{ "type": "DelegatedSignatureEvidence", "transaction_data": { "type": "harbour_delegate:data.purchase", - "credential_ids": ["simpulse_id"], + "credential_ids": ["harbour_natural_person"], "nonce": "da9b1009", "iat": 1771934400, "txn": { @@ -168,7 +171,7 @@ evidence = [{ "currency": "ENVITED" } }, - "delegatedTo": "did:web:signing-service.envited.io" + "delegatedTo": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ" }] # Create VP with selective disclosure (redact PII) @@ -178,7 +181,7 @@ sd_jwt_vp = issue_sd_jwt_vp( disclosures=["memberOf"], # Only disclose non-PII claims evidence=evidence, nonce="da9b1009", - audience="did:web:signing-service.envited.io" + audience="did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ" ) ``` @@ -193,7 +196,7 @@ const sdJwtVp = await issueSdJwtVp(sdJwtVc, holderPrivateKey, { type: 'DelegatedSignatureEvidence', transaction_data: { type: 'harbour_delegate:data.purchase', - credential_ids: ['simpulse_id'], + credential_ids: ['harbour_natural_person'], nonce: 'da9b1009', iat: 1771934400, txn: { @@ -202,10 +205,10 @@ const sdJwtVp = await issueSdJwtVp(sdJwtVc, holderPrivateKey, { currency: 'ENVITED' } }, - delegatedTo: 'did:web:signing-service.envited.io' + delegatedTo: 'did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ' }], nonce: 'da9b1009', - audience: 'did:web:signing-service.envited.io' + audience: 'did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ' }); ``` @@ -223,7 +226,7 @@ result = verify_sd_jwt_vp( issuer_public_key, # From credential issuer's DID holder_public_key, # From user's DID document expected_nonce="da9b1009", - expected_audience="did:web:signing-service.envited.io" + expected_audience="did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ" ) # Check transaction data matches original request @@ -244,11 +247,11 @@ After executing the transaction, the signing service issues a **receipt credenti ```json { "type": ["VerifiableCredential", "harbour:DelegatedSigningReceipt"], - "issuer": "did:web:signing-service.envited.io", + "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", "evidence": [{ "type": "harbour:DelegatedSignatureEvidence", "verifiablePresentation": "", - "delegatedTo": "did:web:signing-service.envited.io", + "delegatedTo": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", "transaction_data": { "..." } }], "credentialStatus": [{ @@ -292,7 +295,7 @@ The `audience` field ensures the VP was created for a specific verifier: verify_sd_jwt_vp( vp, ..., - expected_audience="did:web:signing-service.envited.io" + expected_audience="did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ" ) ``` @@ -313,8 +316,8 @@ if is_revoked: Verify the VP signature matches the public key in the user's DID document: ```python -# Resolve DID document -did_doc = resolve_did("did:web:carlo.simpulse.io") +# Resolve DID document (integrator-provided resolver) +did_doc = resolve_did("did:webs:users.altme.example:natural-persons:550e8400-...:EKYGGh-...") # Extract public key public_key = did_doc["verificationMethod"][0]["publicKeyJwk"] diff --git a/docs/guide/evidence.md b/docs/guide/evidence.md index ef4705a..7233066 100644 --- a/docs/guide/evidence.md +++ b/docs/guide/evidence.md @@ -15,33 +15,13 @@ Evidence creates an **audit trail** — allowing third parties to verify not jus ### CredentialEvidence -Proves that the issuer verified claims using a prior credential or verifiable presentation. The embedded VP contains the credentials the issuer relied upon (e.g., email verification, notary attestation). +Proves that an authorizing party approved the credential issuance via OID4VP. The embedded VP carries the authorization proof — a Verifiable Presentation containing the authorizer's credential. -**Use case (email verification)**: A `NaturalPersonCredential` includes evidence that the user's email was verified via an email verification service (e.g., Altme EmailPass). -The EmailPass proof is modeled as a VC issued by a `did:webs` issuer DID. +The Harbour Signing Service is the **sole issuer** of all credentials. Evidence VPs establish the chain of authorization: -```json -{ - "type": "harbour:CredentialEvidence", - "verifiablePresentation": { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiablePresentation"], - "holder": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", - "verifiableCredential": [ - { - "type": ["VerifiableCredential"], - "issuer": "did:webs:issuers.altme.example:legal-persons:altme_sas:EMtR9m3wZ5xV2k8sP4jQ7nH1cD6bL0fYgAaUu2hCqK9M", - "credentialSubject": { - "type": "EmailPass", - "email": "alice@example.com" - } - } - ] - } -} -``` +**Use case 1 — Trust Anchor authorizes org (LegalPersonCredential)**: The Trust Anchor presents a VP containing its **self-signed LegalPersonCredential** (root of trust, analogous to a root CA certificate). The Signing Service verifies this VP and issues the org's credential with it as evidence. -**Use case (notary attestation)**: A `LegalPersonCredential` includes evidence of a prior credential from a notary attesting to the organization's registration. +**Use case 2 — Org authorizes employee (NaturalPersonCredential)**: The organization presents a VP containing its **LegalPersonCredential** (SD-JWT with sensitive fields redacted — registration number and addresses hidden, name/legalName disclosed). The Signing Service verifies this VP and issues the employee's credential with it as evidence. ```json { @@ -49,15 +29,16 @@ The EmailPass proof is modeled as a VC issued by a `did:webs` issuer DID. "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "holder": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", "verifiableCredential": [ { - "type": ["VerifiableCredential"], - "issuer": "did:web:notary.example.com", + "@context": ["https://www.w3.org/ns/credentials/v2", "..."], + "type": ["VerifiableCredential", "harbour:LegalPersonCredential"], + "issuer": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", "credentialSubject": { - "type": "gx:LegalPerson", - "gx:legalName": "Example Corporation GmbH", - "gx:registrationNumber": "DE123456789" + "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "type": "harbour:LegalPerson", + "name": "ReachHaven GmbH" } } ] @@ -65,7 +46,7 @@ The EmailPass proof is modeled as a VC issued by a `did:webs` issuer DID. } ``` -**What it proves**: The issuer based the credential on a prior attestation from another trusted party. +**What it proves**: The authorizing party (Trust Anchor or org) approved the Signing Service to issue a credential for the target subject. The chain of trust flows: Trust Anchor → org → employee. ### DelegatedSignatureEvidence @@ -77,10 +58,10 @@ Evidence on a **receipt credential** (SD-JWT-VC) that a signing service executed { "type": "harbour:DelegatedSignatureEvidence", "verifiablePresentation": "", - "delegatedTo": "did:web:signing-service.envited.io", + "delegatedTo": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", "transaction_data": { "type": "harbour_delegate:data.purchase", - "credential_ids": ["simpulse_id"], + "credential_ids": ["harbour_natural_person"], "transaction_data_hashes_alg": ["sha-256"], "nonce": "da9b1009", "iat": 1771934400, @@ -91,7 +72,7 @@ Evidence on a **receipt credential** (SD-JWT-VC) that a signing service executed "marketplace": "did:web:dataspace.envited.io" } }, - "challenge": "da9b1009 HARBOUR_DELEGATE c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f" + "challenge": "da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52" } ``` @@ -113,7 +94,7 @@ The receipt credential is an **SD-JWT-VC**. Transaction data and identity detail | Evidence Type | Use When | Example Scenario | |--------------|----------|------------------| -| `CredentialEvidence` | Issuing credential based on prior attestation | Email verification, notary credential, identity proofing | +| `CredentialEvidence` | Issuing credential after authorization from a trusted party | Trust Anchor authorizes org issuance; org authorizes employee issuance | | `DelegatedSignatureEvidence` | Issuing receipt after delegated action | Blockchain purchase, contract signing, access delegation | ## Evidence Structure @@ -172,12 +153,12 @@ When issuing a credential with evidence: credential = { "@context": [...], "type": ["VerifiableCredential", "harbour:NaturalPersonCredential"], - "issuer": "did:web:issuer.example.com", + "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", "credentialSubject": {...}, "evidence": [ { "type": "harbour:CredentialEvidence", - "verifiablePresentation": email_verification_vp_jwt + "verifiablePresentation": authorization_vp_jwt } ] } diff --git a/docs/specs/delegation-challenge-encoding.md b/docs/specs/delegation-challenge-encoding.md index 18c05e0..dfa82d9 100644 --- a/docs/specs/delegation-challenge-encoding.md +++ b/docs/specs/delegation-challenge-encoding.md @@ -48,13 +48,10 @@ Where: ### 2.2 Example ``` -da9b1009 HARBOUR_DELEGATE c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f +da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52 ``` -This format is inspired by [simpulse-id-credentials](https://github.com/ASCS-eV/simpulse-id-credentials) which uses: -``` - ISSUE_PAYLOAD -``` +This format uses a compact, single-line structure designed for QR code presentation while maintaining full auditability via the hash binding. ### 2.3 ABNF Grammar (RFC 5234) @@ -148,12 +145,12 @@ Important: `txn` keys are part of canonicalization and hashing. Renaming a key ( ```json { "type": "harbour_delegate:data.purchase", - "credential_ids": ["simpulse_id"], + "credential_ids": ["harbour_natural_person"], "transaction_data_hashes_alg": ["sha-256"], "nonce": "da9b1009", "iat": 1771934400, "exp": 1771935300, - "description": "Purchase sensor data package from BMW", + "description": "Purchase sensor data package", "txn": { "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", @@ -180,14 +177,14 @@ def compute_transaction_hash(transaction_data: dict) -> str: The resulting challenge: ``` -da9b1009 HARBOUR_DELEGATE c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f +da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52 ``` --- ## 4. VP Evidence Structure (W3C VC 2.0 Compliant) -The delegated consent is captured as `evidence` in a Verifiable Credential or directly as the VP. This follows the pattern from [simpulse-id-credentials](https://github.com/ASCS-eV/simpulse-id-credentials/pull/24). +The delegated consent is captured as `evidence` in a Verifiable Credential or directly as the VP. ### 4.1 Evidence with Embedded VP @@ -195,17 +192,17 @@ The delegated consent is captured as `evidence` in a Verifiable Credential or di { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiableCredential"], - "issuer": "did:web:harbour.signing-service.example.com", + "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", "validFrom": "2026-02-24T12:00:00Z", "credentialSubject": { - "id": "did:web:user.example.com" + "id": "did:webs:users.example.com:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP" }, "evidence": [{ "type": ["CredentialEvidence"], "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:web:user.example.com", + "holder": "did:webs:users.example.com:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", "verifiableCredential": [ "" ], @@ -213,9 +210,9 @@ The delegated consent is captured as `evidence` in a Verifiable Credential or di "type": "DataIntegrityProof", "cryptosuite": "ecdsa-rdfc-2019", "proofPurpose": "authentication", - "challenge": "da9b1009 HARBOUR_DELEGATE c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f", - "domain": "did:web:harbour.signing-service.example.com", - "verificationMethod": "did:web:user.example.com#key-1", + "challenge": "da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52", + "domain": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "verificationMethod": "did:webs:users.example.com:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP#key-1", "created": "2026-02-24T12:00:05Z", "proofValue": "z5vgFc..." } @@ -282,7 +279,7 @@ This specification is designed for seamless integration with [OpenID for Verifia ```json { "type": "harbour_delegate:data.purchase", - "credential_ids": ["simpulse_id"], + "credential_ids": ["harbour_natural_person"], "transaction_data_hashes_alg": ["sha-256"], "nonce": "da9b1009", "iat": 1771934400, @@ -302,7 +299,7 @@ Per OID4VP Appendix B.3.3, the KB-JWT includes: ```json { "nonce": "n-0S6_WzA2Mj", - "aud": "did:web:harbour.signing-service.example.com", + "aud": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", "iat": 1709838604, "sd_hash": "Dy-RYwZfaaoC3inJbLslgPvMp09bH-clYP_3qbRqtW4", "transaction_data_hashes": ["7W0LFUTpMvb6nJK7ngamNNY0zNvxqJ-2jNXTmLzhWQE"], @@ -381,7 +378,7 @@ tx = TransactionData.create( "currency": "ENVITED", "marketplace": "did:web:dataspace.envited.io", }, - credential_ids=["simpulse_id"], + credential_ids=["harbour_natural_person"], ) # Create challenge: " HARBOUR_DELEGATE " @@ -410,7 +407,7 @@ const tx = createTransactionData({ currency: 'ENVITED', marketplace: 'did:web:dataspace.envited.io', }, - credentialIds: ['simpulse_id'], + credentialIds: ['harbour_natural_person'], }); // Create challenge: " HARBOUR_DELEGATE " @@ -428,19 +425,19 @@ Following the design philosophy of [SIWE (EIP-4361)](https://eips.ethereum.org/E ### 9.1 Display Format ``` -╔══════════════════════════════════════════════════════════════╗ -║ Harbour Signing Service requests your authorization ║ -╠══════════════════════════════════════════════════════════════╣ -║ ║ -║ Action: Purchase data asset ║ -║ Asset: urn:uuid:550e8400-e29b-41d4-a716-44665544... ║ -║ Amount: 100 ENVITED ║ -║ ║ -╠══════════════════════════════════════════════════════════════╣ -║ Service: did:web:harbour.signing-service.example.com ║ -║ Nonce: da9b1009 ║ -║ Time: 2026-02-24 12:00:00 UTC ║ -╚══════════════════════════════════════════════════════════════╝ +╔═══════════════════════════════════════════════════════════════════════╗ +║ Harbour Signing Service requests your authorization ║ +╠═══════════════════════════════════════════════════════════════════════╣ +║ ║ +║ Action: Purchase data asset ║ +║ Asset: urn:uuid:550e8400-e29b-41d4-a716-44665544... ║ +║ Amount: 100 ENVITED ║ +║ ║ +╠═══════════════════════════════════════════════════════════════════════╣ +║ Service: did:webs:harbour.reachhaven.com:Er9_mnFst... ║ +║ Nonce: da9b1009 ║ +║ Time: 2026-02-24 12:00:00 UTC ║ +╚═══════════════════════════════════════════════════════════════════════╝ ``` ### 9.2 Display Requirements @@ -504,7 +501,7 @@ These examples use the shared test vectors from `tests/fixtures/canonicalization ```json { "type": "harbour_delegate:data.purchase", - "credential_ids": ["simpulse_id"], + "credential_ids": ["harbour_natural_person"], "transaction_data_hashes_alg": ["sha-256"], "nonce": "da9b1009", "iat": 1771934400, @@ -519,7 +516,7 @@ These examples use the shared test vectors from `tests/fixtures/canonicalization **Challenge:** ``` -da9b1009 HARBOUR_DELEGATE c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f +da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52 ``` ### 10.2 Blockchain Transfer Transaction @@ -560,7 +557,7 @@ ef567890 HARBOUR_DELEGATE 0736db89c15be412294f96717a3e435f89d095e7e953b1808c4222 "description": "Sign partnership agreement", "txn": { "document_hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "parties": ["did:web:alice.example", "did:web:bob.example"] + "parties": ["did:webs:alice.example:EAbc123", "did:webs:bob.example:EDef456"] } } ``` @@ -609,11 +606,11 @@ OID4VP authorization request: ```json { "response_type": "vp_token", - "client_id": "did:web:signing-service.envited.io", + "client_id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", "nonce": "da9b1009", "transaction_data": [{ "type": "harbour_delegate:data.purchase", - "credential_ids": ["simpulse_id"], + "credential_ids": ["harbour_natural_person"], "transaction_data_hashes_alg": ["sha-256"], "nonce": "da9b1009", "iat": 1771934400, diff --git a/docs/specs/did-method-evaluation.md b/docs/specs/did-method-evaluation.md index b4cee07..f87c64c 100644 --- a/docs/specs/did-method-evaluation.md +++ b/docs/specs/did-method-evaluation.md @@ -1,16 +1,16 @@ # DID Method Evaluation: did:web vs did:webs -**Version**: 1.0.0 -**Date**: 2026-02-24 +**Version**: 2.0.0 +**Date**: 2026-02-26 **Status**: Decision Record --- ## 1. Executive Summary -This document evaluates `did:web` and `did:webs` DID methods for use in Harbour Credentials and the SimpulseID ecosystem. +This document evaluates `did:web` and `did:webs` DID methods for use in Harbour Credentials. -**Decision**: Use `did:web` for v1 with documented key rotation practices. Consider `did:webs` migration for v2 when tooling matures. +**Decision**: Use `did:webs` for all Harbour identities (infrastructure and participants). The wallet-transparent KERI architecture (§8) enables `did:webs` without requiring wallet-side KERI support. --- @@ -121,50 +121,35 @@ Most VC wallets support did:web natively. did:webs support is limited to KERI-sp --- -## 7. Current SimpulseID Implementation (did:web) - -Our current did:web implementation includes key rotation best practices: - -### 7.1 Key Rotation Model - -From `examples/did-web/README.md`: - -1. **Stable fragment IDs**: Key fragments (`#wallet-key-1`) never change -2. **Revocation timestamps**: Old keys marked with `"revoked": ""` -3. **Active key tracking**: Only non-revoked keys in `assertionMethod` - -```json -{ - "verificationMethod": [ - { - "id": "did:web:example.com:users:alice#wallet-key-1", - "type": "JsonWebKey", - "publicKeyJwk": { "kty": "EC", "crv": "P-256", ... }, - "revoked": "2026-01-15T00:00:00Z" - }, - { - "id": "did:web:example.com:users:alice#wallet-key-2", - "type": "JsonWebKey", - "publicKeyJwk": { "kty": "EC", "crv": "P-256", ... } - } - ], - "assertionMethod": [ - "did:web:example.com:users:alice#wallet-key-2" - ] -} -``` +## 7. Current Harbour Implementation (did:webs) + +Harbour uses `did:webs` identifiers for all entities. The wallet-transparent +KERI architecture (§8) provides cryptographic key history without requiring +wallet-side KERI support. + +### 7.1 DID Structure + +From [`examples/did-webs/`](../../examples/did-webs/): + +| Entity | DID | Keys | +|--------|-----|------| +| Trust Anchor | `did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo` | `#key-1` (assertionMethod) | +| Signing Service | `did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ` | `#key-1` (assertionMethod), `#key-2` (capabilityDelegation) | +| Participants | `did:webs:participants.harbour.reachhaven.com:legal-persons::` | `#key-1` (assertionMethod) | +| Users | `did:webs:users.altme.example:natural-persons::` | `#key-1` (assertionMethod) | ### 7.2 Trust Model -- All DIDs controlled by `did:web:did.ascs.digital:services:trust-anchor` -- ASCS operates the web server (centralized trust anchor) -- Signatures are attestations, not control grants +- Trust Anchor (`did:webs:reachhaven.com:ENVSnGVU...`) is the root of trust +- Signing Service is the sole credential issuer, authorized via evidence VPs +- Trust Anchor has a `LinkedCredentialService` endpoint for its self-signed credential +- Naming policy: all DID paths use UUID segments (never real names or org names) -### 7.3 Limitations +### 7.3 Credential Issuance Chain -- Key history not cryptographically verifiable -- Must trust ASCS to honestly report revocations -- No protection against server compromise +1. Trust Anchor authorizes org → VP with self-signed LegalPersonCredential +2. Org authorizes employee → VP with org's LegalPersonCredential (SD-JWT, PII redacted) +3. Signing Service issues all credentials with authorization VPs as evidence --- @@ -242,65 +227,46 @@ This architecture provides KERI's cryptographic benefits while maintaining compa --- -## 9. Migration Path to did:webs - -If/when did:webs matures, migration could follow this path: +## 9. Migration Status -### Phase 1: Dual Resolution -- Maintain did:web documents as-is -- Add KERI AID to DID documents -- Resolve both methods, prefer did:webs when available +Migration to `did:webs` is complete for identity modeling. All example +identities, DID documents, and credential examples now use `did:webs`. -### Phase 2: KERI Infrastructure -- Deploy KERI witnesses (minimum 3 recommended) -- Set up watchers for duplicity detection -- Migrate high-value DIDs (trust anchor, services) first +### Completed -### Phase 3: Full Migration -- Convert all user DIDs to did:webs -- Deprecate did:web-only resolution -- Update wallet integrations +- [x] All Harbour infrastructure DIDs use `did:webs` (Trust Anchor, Signing Service) +- [x] All participant/user DIDs use `did:webs` with UUID paths +- [x] DID documents created for all actors (`examples/did-webs/`) +- [x] Credential examples updated with `did:webs` issuers and subjects +- [x] Wallet-transparent architecture designed (§8) — any ES256 wallet works -### Prerequisites for Migration (Updated) - -Based on the wallet-transparent architecture (§8), migration prerequisites are significantly reduced: +### Remaining Infrastructure Work - [ ] KERI witness infrastructure deployed (Harbour-operated, 3+ witnesses recommended) - [ ] Rotation signing protocol implemented in Harbour -- [ ] did:webs resolver integrated (or use Universal Resolver) -- [x] ~~3+ major wallets support did:webs~~ **NOT REQUIRED** — Any ES256 wallet works -- [x] ~~did:webs spec reaches 1.0~~ **NOT BLOCKING** — Architecture is spec-compatible +- [ ] did:webs resolver integrated for production verification (or use Universal Resolver) --- ## 10. Recommendation -### For v1 (Current) +### Current -**Use did:web** with the following practices: +**Use did:webs** with the wallet-transparent KERI architecture (§8): 1. ✅ P-256 keys (ES256 algorithm) -2. ✅ Stable fragment IDs for key references -3. ✅ Revocation timestamps (never delete keys) -4. ✅ Trust anchor pattern (centralized control with attestations) -5. ✅ Document key rotation procedures - -### For v2 (Future) - -**Implement wallet-transparent did:webs**: - -1. Deploy KERI infrastructure in Harbour (witnesses, watchers) -2. Implement rotation signing protocol (wallet signs KERI events as regular ES256 payloads) -3. Add did:webs resolution alongside did:web - -**Key insight**: We don't need to wait for wallet ecosystem support. Harbour can provide did:webs benefits to **any ES256-capable wallet** by operating the KERI infrastructure server-side. The wallet just signs—Harbour handles the KERI complexity. +2. ✅ Stable fragment IDs for key references (`#key-1`, `#key-2`) +3. ✅ Trust Anchor with self-signed credential (root of trust) +4. ✅ Signing Service as sole credential issuer +5. ✅ UUID-only DID paths (privacy-preserving) +6. ✅ Wallet-transparent KERI (any ES256 wallet works) -### Migration Prerequisites (Updated) +### Remaining Infrastructure Work -- [ ] KERI witness infrastructure deployed (Harbour-operated) +- [ ] KERI witness infrastructure deployed (Harbour-operated, 3+ witnesses) - [ ] Rotation signing protocol implemented -- [ ] did:webs resolver integrated -- [ ] ~~3+ wallets support did:webs~~ (NOT required with transparent architecture) +- [ ] did:webs resolver integrated for production verification +- [x] ~~3+ wallets support did:webs~~ **NOT REQUIRED** — wallet-transparent architecture --- @@ -334,5 +300,6 @@ Reference specifications are stored in `docs/specs/references/` for offline acce | Version | Date | Changes | |---------|------|---------| +| 2.0.0 | 2026-02-26 | Migrated to did:webs; updated section 7 for current implementation | | 1.1.0 | 2026-02-24 | Updated recommendation based on wallet-transparent KERI insight | | 1.0.0 | 2026-02-24 | Initial evaluation and decision | diff --git a/docs/specs/references/README.md b/docs/specs/references/README.md index 967739b..cf739ad 100644 --- a/docs/specs/references/README.md +++ b/docs/specs/references/README.md @@ -13,13 +13,17 @@ They are copies of specifications published by their respective standards organi | File | Source | Organization | License | |------|--------|--------------|---------| | `oid4vp-1.0.txt` | [OpenID4VP 1.0](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html) | OpenID Foundation | [OpenID IPR](https://openid.net/intellectual-property/) | +| `vc-jose-cose.md` | [VC-JOSE-COSE](https://www.w3.org/TR/vc-jose-cose/) | W3C | [W3C Document License](https://www.w3.org/copyright/document-license-2023/) | +| `sd-jwt-vc.md` | [SD-JWT-VC draft-14](https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/) | IETF | [IETF Trust](https://trustee.ietf.org/license-info) | +| `csc-data-model.md` | [CSC Data Model v1.0.0](https://cloudsignatureconsortium.org/wp-content/uploads/2025/10/data-model-bindings.pdf) | Cloud Signature Consortium | CSC License | | `did-web-method.txt` | [did:web Specification](https://w3c-ccg.github.io/did-method-web/) | W3C CCG | [W3C Document License](https://www.w3.org/copyright/document-license-2023/) | | `did-webs-spec.md` | [did:webs Specification](https://github.com/trustoverip/tswg-did-method-webs-specification) | Trust Over IP Foundation | [ToIP License](https://github.com/trustoverip/tswg-did-method-webs-specification/blob/main/LICENSE.md) | | `keri-draft.md` | [KERI Draft](https://github.com/WebOfTrust/ietf-keri) | WebOfTrust / IETF | Apache 2.0 | ## Download Date -All specifications were downloaded on **2026-02-24**. +- `oid4vp-1.0.txt`, `did-web-method.txt`, `did-webs-spec.md`, `keri-draft.md`: **2026-02-24** +- `vc-jose-cose.md`, `sd-jwt-vc.md`, `csc-data-model.md`: **2026-02-25** ## Usage @@ -60,6 +64,10 @@ Always refer to the original sources for the most up-to-date and legally binding - **KERI**: https://weboftrust.github.io/ietf-keri/draft-ssmith-keri.html - **W3C DID Core**: https://www.w3.org/TR/did-core/ - **W3C VC Data Model**: https://www.w3.org/TR/vc-data-model-2.0/ +- **W3C VC-JOSE-COSE**: https://www.w3.org/TR/vc-jose-cose/ +- **SD-JWT-VC**: https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/ +- **SD-JWT (RFC 9901)**: https://www.rfc-editor.org/rfc/rfc9901 +- **CSC Data Model**: https://cloudsignatureconsortium.org/resources/ ## Disclaimer diff --git a/docs/specs/references/csc-data-model.md b/docs/specs/references/csc-data-model.md new file mode 100644 index 0000000..843e2c0 --- /dev/null +++ b/docs/specs/references/csc-data-model.md @@ -0,0 +1,47 @@ +# CSC Data Model for Remote Signature Applications + +**Status:** CSC Standard, v1.0.0 (October 2025) +**URL:** https://cloudsignatureconsortium.org/wp-content/uploads/2025/10/data-model-bindings.pdf +**API:** CSC API v2.2 (November 2025) + +## Overview + +The CSC Data Model defines how remote signing services (QTSPs) interact with +OID4VP for qualified electronic signature (QES) authorization. It bridges the +CSC API layer with the OID4VP credential presentation layer. + +## Key Concepts + +### Signature Request Flow +1. Relying party creates a `signatureRequest` via CSC API +2. QTSP triggers OID4VP Authorization Request with `transaction_data` +3. Wallet presents credentials + KB-JWT with `transaction_data_hashes` +4. QTSP uses authorized credentials to produce QES + +### Data Model Mapping to Harbour +| CSC Concept | Harbour Equivalent | OID4VP | +|-------------|-------------------|--------| +| `signatureRequest` | `transaction_data` | `transaction_data` (request param) | +| `documentDigests` | `txn.document_hash` | — | +| `credentialID` | `credential_ids` | `credential_ids` | +| `hashAlgorithmOID` (OID) | `transaction_data_hashes_alg` (IANA) | `transaction_data_hashes_alg` | +| `SAD` (Signature Activation Data) | OID4VP consent flow | KB-JWT binding | + +### SCAL2 Requirement +For SCAL2 (Sole Control Assurance Level 2), the authorization MUST be +cryptographically bound to the specific document hashes being signed. +This maps to OID4VP `transaction_data` with hash-bound consent. + +### Integration with OID4VP +CSC-DM defines OID4VP `transaction_data` objects with: +- `type`: action type (e.g., `"sign"`) +- `documentDigests`: array of document hashes +- `hashAlgorithmOID`: hash algorithm (OID format in CSC, IANA name in OID4VP) +- `credentialID`: identifies the signing credential at the QTSP + +## Relationship to Other Specs +- **OID4VP**: CSC uses OID4VP `transaction_data` for authorization +- **RFC 9901**: KB-JWT carries `transaction_data_hashes` proving wallet consent +- **eIDAS 2.0**: QES requirements drive SCAL2 hash-bound authorization +- **Harbour**: `DelegatedSignatureEvidence` captures the delegation receipt + with `transaction_data` as an evidence-level claim on the receipt VC diff --git a/docs/specs/references/sd-jwt-vc.md b/docs/specs/references/sd-jwt-vc.md new file mode 100644 index 0000000..441748b --- /dev/null +++ b/docs/specs/references/sd-jwt-vc.md @@ -0,0 +1,58 @@ +# IETF SD-JWT-VC — SD-JWT-based Verifiable Digital Credentials + +**Status:** Internet Draft (draft-ietf-oauth-sd-jwt-vc-14, Feb 2026) +**URL:** https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/ +**Base:** RFC 9901 (SD-JWT) + +## Key Normative Requirements + +### Relationship to W3C VCDM (§11) +SD-JWT-VC does NOT utilize W3C VCDM v1.0, v1.1, or v2.0. It uses flat JWT +claims rather than JSON-LD structure. There is no `@context` or `type` array. + +### Required/Optional Claims +| Claim | Requirement | Notes | +|-------|-------------|-------| +| `vct` | REQUIRED | Credential type (URI string, replaces `type` array) | +| `iss` | OPTIONAL (draft-14) | Was REQUIRED in draft-08. Can use x5c instead | +| `iat` | OPTIONAL | Issuance time (selectively disclosable) | +| `nbf` | OPTIONAL | Not before (not selectively disclosable) | +| `exp` | OPTIONAL | Expiration (MUST NOT be selectively disclosable) | +| `sub` | OPTIONAL | Subject identifier | +| `cnf` | CONDITIONAL | REQUIRED when key binding is used | +| `status` | OPTIONAL | MUST NOT be selectively disclosable | + +### `typ` Header Change +| Version | `typ` value | Media type | +|---------|-------------|------------| +| draft-08 | `vc+sd-jwt` | `application/vc+sd-jwt` | +| draft-14 | `dc+sd-jwt` | `application/dc+sd-jwt` | + +Renamed to avoid conflict with W3C VC-JOSE-COSE's `application/vc+sd-jwt` +which carries full JSON-LD payload. Verifiers SHOULD accept both during transition. + +### Status (§3.2) +The `status` claim MUST NOT be selectively disclosable. Uses `status_list` +sub-object with `idx` (integer) and `uri` (status list URL). + +### Key Binding (§4, via RFC 9901) +- KB-JWT REQUIRED claims: `iat`, `aud`, `nonce`, `sd_hash` +- `sd_hash` computed over US-ASCII bytes of entire SD-JWT before KB-JWT: + `~~...~~` +- KB-JWT `typ` header: `kb+jwt` + +### Custom Claims (§11) +Custom claims are allowed. `evidence` is not defined by SD-JWT-VC but can be +added as a custom claim and MAY be selectively disclosable. + +## Mapping to W3C VCDM +| W3C VCDM | SD-JWT-VC | Notes | +|----------|-----------|-------| +| `type` array | `vct` | URI string, not array | +| `issuer` | `iss` | URI string | +| `credentialSubject.id` | `sub` | URI string | +| `validFrom` | `iat` / `nbf` | NumericDate, not ISO 8601 | +| `validUntil` | `exp` | NumericDate, not ISO 8601 | +| `credentialStatus` | `status` | Different structure | +| `evidence` | custom claim | Not defined by spec | +| `@context` | not used | No JSON-LD | diff --git a/docs/specs/references/vc-jose-cose.md b/docs/specs/references/vc-jose-cose.md new file mode 100644 index 0000000..6afa72f --- /dev/null +++ b/docs/specs/references/vc-jose-cose.md @@ -0,0 +1,56 @@ +# W3C VC-JOSE-COSE — Securing Verifiable Credentials using JOSE and COSE + +**Status:** W3C Recommendation, 15 May 2025 +**URL:** https://www.w3.org/TR/vc-jose-cose/ + +## Key Normative Requirements + +### Payload Structure (§3.1) +- The entire VC JSON-LD document IS the JWT Claims Set (enveloping proof model). +- The JWT Claim Names `vc` and `vp` MUST NOT be present (§1.1.2.1, §3.1.3). +- Implementations MUST support JWS compact serialization; JSON serialization NOT RECOMMENDED. + +### Media Types (§6.1) +| Media Type | Purpose | +|------------|---------| +| `application/vc+jwt` | JWT-secured credentials | +| `application/vp+jwt` | JWT-secured presentations | +| `application/vc+sd-jwt` | SD-JWT-secured credentials | +| `application/vp+sd-jwt` | SD-JWT-secured presentations | +| `application/vc+cose` | COSE-secured credentials | +| `application/vp+cose` | COSE-secured presentations | + +**Note:** No `+ld+` media types exist (e.g., `vc+ld+jwt` is NOT valid). + +### `typ` Header (§3.1.1, §3.1.2, §3.2.1, §3.2.2) +| Context | `typ` SHOULD be | +|---------|-----------------| +| JOSE VC | `vc+jwt` | +| JOSE VP | `vp+jwt` | +| SD-JWT VC | `vc+sd-jwt` | +| SD-JWT VP | `vp+sd-jwt` | + +### Claim/Property Conflict Avoidance (§3.1.3) +| JWT Claim | VC Property | Guidance | +|-----------|-------------|----------| +| `iss` | `issuer` | SHOULD NOT conflict | +| `jti` | `id` | SHOULD NOT conflict | +| `sub` | `credentialSubject.id` | SHOULD NOT conflict | +| `iat` | `validFrom` | Different semantics (signature vs credential time) | +| `exp` | `validUntil` | Different semantics (signature vs credential expiry) | + +Use of `nbf` is NOT RECOMMENDED (§3.1.3). + +### SD-JWT Non-Disclosable Properties (§3.2.1) +Properties that SHOULD NOT be selectively disclosable: +- `@context`, `type`, `credentialStatus`, `credentialSchema`, `relatedResource` + +### Key Discovery (§4.1, §4.2) +- `kid` MUST be present when key is expressed as DID URL (§4.1.1). +- Verification method type MUST be `JsonWebKey`; key MUST be in `publicKeyJwk` (§4.2). +- `cnf` MAY identify proof-of-possession key per RFC 7800 (§4.1.3). + +### Verification (§5) +- Verified document MUST be well-formed compact JSON-LD per VCDM2. +- All claims for `typ` MUST be present and evaluated per validation policies. +- Claims not understood MUST be ignored. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..5647454 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,404 @@ +# Harbour Credentials — Example User Journey + +This folder contains a complete, end-to-end example of the Harbour credential +lifecycle: from organization onboarding through employee credentialing to a +delegated blockchain transaction with privacy-preserving audit. + +## Overview + +```mermaid +flowchart LR + subgraph "1. Org Onboarding" + TA[Trust Anchor
did:webs] -->|authorization VP| SS1[Signing Service] + SS1 -->|LegalPersonCredential| LP[Legal Person
did:webs] + end + subgraph "2. Employee Onboarding" + LP -->|authorization VP
SD-JWT, PII redacted| SS2[Signing Service] + SS2 -->|NaturalPersonCredential| NP[Alice
did:webs] + NP -.->|memberOf| LP + end + subgraph "3. Consent" + NP -->|SD-JWT VP + KB-JWT| SS3[Signing Service] + end + subgraph "4. Receipt" + SS3 -->|execute txn| BC[Blockchain] + SS3 -->|DelegatedSigningReceipt| NP + end +``` + +## Credential Issuance Model + +The Harbour Signing Service is the **sole issuer** of all credentials, acting +"on behalf of" an authorizing party. The `evidence` field on each credential +contains a VP proving who authorized the issuance: + +- **LegalPersonCredential**: Trust Anchor authorizes the org by presenting a VP + containing its self-signed LegalPersonCredential. The Signing Service issues + the credential with this VP as evidence. +- **NaturalPersonCredential**: Org authorizes the employee by presenting a VP + containing its own LegalPersonCredential (SD-JWT, sensitive fields redacted). + The Signing Service issues the credential with this VP as evidence. + +### Trust Anchor Self-Signed Credential + +The Trust Anchor holds a **self-signed LegalPersonCredential** (analogous to a +root CA certificate) where `issuer == credentialSubject.id`. This credential is +publicly resolvable via a `LinkedCredentialService` endpoint in the Trust +Anchor's DID document. See [`trust-anchor-credential.json`](trust-anchor-credential.json). + +## Actors and Identities + +Every actor has a `did:webs` identity (KERI-backed, long-lived). Users also have +a `did:jwk` wallet key (P-256) in the Altme wallet. When a user requests a +credential, the authorizing party presents a VP to the Signing Service. Harbour +then creates the `did:webs` identifier and embeds **the same P-256 public key** +from the wallet into the new `did:webs` DID document. + +| Actor | Role | Identity (`did:webs`) | DID Document | +|-------|------|-----------------------|--------------| +| **Harbour Trust Anchor** | Root of trust, authorizes orgs | `did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo` | [`harbour-trust-anchor.did.json`](did-webs/harbour-trust-anchor.did.json) | +| **Harbour Signing Service** | Issues ALL credentials (`#key-1`), signs delegated txns (`#key-2`) | `did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ` | [`harbour-signing-service.did.json`](did-webs/harbour-signing-service.did.json) | +| **Example Corporation GmbH** | Legal person (organization) | `did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-...:ENro7uf0eP...` | [`legal-person-0aa6d7ea-...did.json`](did-webs/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json) | +| **Alice Smith** | Natural person (employee) | `did:webs:users.altme.example:natural-persons:550e8400-...:EKYGGh-Ft...` | [`natural-person-550e8400-...did.json`](did-webs/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json) | +| **ENVITED Marketplace** | Data marketplace (external) | `did:web:dataspace.envited.io` | — | + +> **Privacy note**: All `did:webs` identifiers use UUID path segments — never +> real names or organization names. This prevents DID IRIs from leaking identity +> information at the public layer. + +### Signing Service Key Roles + +The Signing Service DID document contains two verification methods: + +| Key | Relationship | Purpose | +|-----|-------------|---------| +| `#key-1` | `assertionMethod` | Credential issuance (signs all VCs) | +| `#key-2` | `capabilityDelegation` | Delegated transaction signing | + +Both keys are listed under `authentication`. + +--- + +## Step 1: Organization Onboarding — LegalPersonCredential + +The Trust Anchor authorizes the Signing Service to issue a `LegalPersonCredential` +for an organization. The Trust Anchor presents a VP containing its **self-signed +LegalPersonCredential** to the Signing Service, which then issues the credential +with this VP as evidence. + +```mermaid +sequenceDiagram + participant TA as Trust Anchor
(did:webs) + participant SS as Signing Service
(did:webs) + participant DW as did:webs Registry + + TA->>SS: Authorize org credential issuance + TA->>TA: Create VP with self-signed
LegalPersonCredential + TA->>SS: Authorization VP (Trust Anchor's credential inside) + SS->>SS: Verify VP + Trust Anchor credential + SS->>DW: Create did:webs for legal person + SS->>SS: Sign LegalPersonCredential
(evidence = Trust Anchor's VP) + SS->>DW: Deliver LegalPersonCredential +``` + +**What the evidence proves**: The Trust Anchor (root of trust) authorized the +Signing Service to issue this credential. The VP contains the Trust Anchor's +self-signed LegalPersonCredential, establishing the chain of trust. + +### Example files + +| File | Description | +|------|-------------| +| [`trust-anchor-credential.json`](trust-anchor-credential.json) | Trust Anchor's self-signed credential (root of trust) | +| [`legal-person-credential.json`](legal-person-credential.json) | Unsigned credential (expanded JSON-LD) | +| [`signed/legal-person-credential.jwt`](signed/legal-person-credential.jwt) | Signed credential (VC-JOSE-COSE wire format) | +| [`signed/legal-person-credential.decoded.json`](signed/legal-person-credential.decoded.json) | Decoded JWT (header + payload) | +| [`signed/legal-person-credential.evidence-vp.jwt`](signed/legal-person-credential.evidence-vp.jwt) | Evidence VP (Trust Anchor authorization) | +| [`did-webs/legal-person-0aa6d7ea-...did.json`](did-webs/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json) | Legal person DID document | + +### Code + +```python +# Python — sign the credential +from harbour.signer import sign_vc_jose +signed_jwt = sign_vc_jose(credential, private_key, kid=issuer_kid) +``` + +```typescript +// TypeScript — sign the credential +import { signJwt } from '@reachhaven/harbour-credentials'; +const signedJwt = await signJwt(credential, privateKey, { kid: issuerKid }); +``` + +--- + +## Step 2: Employee Onboarding — NaturalPersonCredential + +The organization authorizes the Signing Service to issue a `NaturalPersonCredential` +for an employee. The org presents a VP containing its **LegalPersonCredential** +(SD-JWT with sensitive fields redacted — registration number and addresses hidden, +name/legalName disclosed). The Signing Service issues the credential with this VP +as evidence. + +```mermaid +sequenceDiagram + participant ORG as Organization
(did:webs) + participant SS as Signing Service
(did:webs) + participant DW as did:webs Registry + + ORG->>SS: Authorize employee credential issuance + ORG->>ORG: Create VP with LegalPersonCredential
(SD-JWT, PII redacted) + ORG->>SS: Authorization VP (org credential inside) + SS->>SS: Verify VP + org credential
(name disclosed, PII redacted) + SS->>DW: Create did:webs for natural person + SS->>SS: Sign NaturalPersonCredential
(evidence = org's VP, memberOf link) + SS->>DW: Deliver NaturalPersonCredential +``` + +**Chain of trust**: The Trust Anchor authorized the org (Step 1), the org +authorizes the employee (Step 2), and the Signing Service issues both credentials. +The `memberOf` field references the legal person's opaque `did:webs` identifier +(UUID-based, no company name). A verifier can resolve this DID to confirm +organizational affiliation without the credential itself leaking PII. + +> **Discussion point**: `memberOf` is currently selectively disclosable. Whether +> it should be always-disclosed (to guarantee the trust chain) or remain +> optional (for maximum privacy) is an open design decision. + +### Example files + +| File | Description | +|------|-------------| +| [`natural-person-credential.json`](natural-person-credential.json) | Unsigned credential (expanded JSON-LD) | +| [`signed/natural-person-credential.jwt`](signed/natural-person-credential.jwt) | Signed credential (VC-JOSE-COSE wire format) | +| [`signed/natural-person-credential.decoded.json`](signed/natural-person-credential.decoded.json) | Decoded JWT (header + payload) | +| [`signed/natural-person-credential.evidence-vp.jwt`](signed/natural-person-credential.evidence-vp.jwt) | Evidence VP (org authorization) | +| [`did-webs/natural-person-550e8400-...did.json`](did-webs/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json) | Alice's DID document | + +### Code + +```python +# Python — convert to SD-JWT-VC flat claims +from credentials.claim_mapping import vc_to_sd_jwt_claims, MAPPINGS +mapping = MAPPINGS["harbour:NaturalPersonCredential"] +claims, disclosable = vc_to_sd_jwt_claims(credential, mapping) +# claims: {"iss": ..., "vct": ..., "givenName": "Alice", "memberOf": "did:webs:..."} +# disclosable: ["givenName", "familyName", "email", "memberOf"] +``` + +--- + +## Step 3: Delegated Transaction — Consent VP + +Alice wants to buy a data asset on the ENVITED marketplace. Instead of signing the +blockchain transaction directly, she delegates it to the Harbour Signing Service. + +The signing service creates an OID4VP `transaction_data` object describing the +purchase. Alice's wallet creates an **SD-JWT VP** with: + +- Her `NaturalPersonCredential` (PII redacted — only `memberOf` disclosed) +- A **KB-JWT** binding her signature to the `transaction_data` hash +- `DelegatedSignatureEvidence` with the challenge string + +```mermaid +sequenceDiagram + participant A as Alice's Wallet
(did:jwk) + participant SS as Signing Service
(did:webs) + participant MP as ENVITED Marketplace + + A->>SS: "Buy asset X for 100 ENVITED" + SS->>SS: Create transaction_data
(asset, price, nonce, timestamp) + SS->>SS: Compute challenge:
nonce HARBOUR_DELEGATE sha256(tx_data) + SS->>A: OID4VP authorization request
(transaction_data, nonce, audience) + A->>A: Review transaction details + A->>A: Select disclosures (redact PII) + A->>A: Create SD-JWT VP:
• NaturalPersonCredential (memberOf only)
• KB-JWT (sd_hash + tx_data_hash)
• DelegatedSignatureEvidence + A->>SS: SD-JWT VP (consent proof) + SS->>SS: Verify VP:
✓ Credential signature
✓ KB-JWT binding
✓ transaction_data_hash match
✓ Challenge integrity +``` + +**What Alice discloses** (selective disclosure): + +| Claim | Disclosed? | Why | +|-------|-----------|-----| +| `memberOf` | Yes | Trust chain — proves organizational affiliation | +| `name` | Yes | Non-PII display name | +| `givenName` | No | PII — redacted | +| `familyName` | No | PII — redacted | +| `email` | No | PII — redacted | + +### Wire format + +On the wire, the consent VP is an SD-JWT compact serialization: +`~~...~` + +The consent VP is not persisted as a standalone example — it is an ephemeral +artifact between Alice's wallet and the Signing Service. The receipt credential +([`delegated-signing-receipt.json`](delegated-signing-receipt.json)) embeds the +consent VP as evidence, making it the durable audit record. + +### Code + +```python +# Python — create delegation challenge +from harbour.delegation import TransactionData, create_delegation_challenge + +tx = TransactionData.create( + action="data.purchase", + txn={ + "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "price": "100", + "currency": "ENVITED", + "marketplace": "did:web:dataspace.envited.io", + }, + credential_ids=["harbour_natural_person"], +) +challenge = create_delegation_challenge(tx) +# "da9b1009 HARBOUR_DELEGATE cb991694..." +``` + +```python +# Python — create consent VP with selective disclosure +from harbour.sd_jwt_vp import issue_sd_jwt_vp + +sd_jwt_vp = issue_sd_jwt_vp( + alice_sd_jwt_vc, + alice_private_key, + disclosures=["memberOf"], # only disclose non-PII + evidence=[{ + "type": "DelegatedSignatureEvidence", + "transaction_data": tx.to_dict(), + "delegatedTo": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + }], + nonce=tx.nonce, + audience="did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", +) +``` + +```typescript +// TypeScript equivalents +import { + createTransactionData, createDelegationChallenge, + issueSdJwtVp, +} from '@reachhaven/harbour-credentials'; +``` + +--- + +## Step 4: Transaction Execution — DelegatedSigningReceipt + +The signing service verifies Alice's consent VP, executes the blockchain purchase, +and issues a **receipt credential** (`DelegatedSigningReceipt`) with the consent +proof embedded as evidence. + +```mermaid +sequenceDiagram + participant SS as Signing Service
(did:webs) + participant BC as Blockchain + participant A as Alice's Wallet + + SS->>SS: Verify consent VP:
✓ VC signature (issuer key)
✓ KB-JWT (holder key)
✓ transaction_data_hash
✓ Challenge matches
✓ CRSet not revoked + SS->>BC: Execute purchase transaction + BC->>SS: Transaction ID (0xabcdef...) + SS->>SS: Sign DelegatedSigningReceipt:
• evidence = consent VP (SD-JWT)
• transaction_data
• blockchainTxId
• CRSet entry + SS->>A: DelegatedSigningReceipt (JWT) +``` + +### Three-Layer Privacy Model + +The receipt credential is an **SD-JWT-VC**. Different audiences see different +layers of information: + +| Layer | Audience | What's Visible | +|-------|----------|----------------| +| **Layer 1 — Public** | Everyone | CRSet entry (credential exists), `transactionHash` on-chain, DID identifiers (opaque UUIDs), KB-JWT signature valid | +| **Layer 2 — Authorized** | Auditor | Transaction details (asset, price, marketplace), consent VP hash verification, `memberOf` (organization DID) | +| **Layer 3 — Full Audit** | Compliance | User identity (name, email, organization name), full credential chain | + +### Example files + +| File | Description | +|------|-------------| +| [`delegated-signing-receipt.json`](delegated-signing-receipt.json) | Unsigned receipt (expanded JSON-LD) | +| [`signed/delegated-signing-receipt.jwt`](signed/delegated-signing-receipt.jwt) | Signed receipt (VC-JOSE-COSE wire format) | +| [`signed/delegated-signing-receipt.decoded.json`](signed/delegated-signing-receipt.decoded.json) | Decoded JWT (header + payload) | +| [`signed/delegated-signing-receipt.evidence-vp.jwt`](signed/delegated-signing-receipt.evidence-vp.jwt) | Evidence VP (consent proof, signed) | + +### Code + +```python +# Python — verify consent VP +from harbour.sd_jwt_vp import verify_sd_jwt_vp + +result = verify_sd_jwt_vp( + sd_jwt_vp, + issuer_public_key, + holder_public_key, + expected_nonce="da9b1009", + expected_audience="did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", +) + +# Python — sign receipt credential +from harbour.signer import sign_vc_jose +receipt_jwt = sign_vc_jose(receipt, service_private_key, kid=service_kid) +``` + +```typescript +// TypeScript equivalents +import { verifySdJwtVp, signJwt } from '@reachhaven/harbour-credentials'; +``` + +--- + +## File Index + +### Credential examples (unsigned, expanded JSON-LD) + +| File | Step | Description | +|------|------|-------------| +| [`trust-anchor-credential.json`](trust-anchor-credential.json) | — | Trust Anchor self-signed credential (root of trust) | +| [`legal-person-credential.json`](legal-person-credential.json) | 1 | Organization credential with Gaia-X compliance data | +| [`natural-person-credential.json`](natural-person-credential.json) | 2 | Employee credential with `memberOf` link | +| [`delegated-signing-receipt.json`](delegated-signing-receipt.json) | 3+4 | Transaction receipt with embedded consent VP as evidence | + + +### Signed artifacts (`signed/`) + +For each credential, the signer produces: + +| Suffix | Content | +|--------|---------| +| `.jwt` | Signed VC-JOSE-COSE compact JWS (wire format) | +| `.decoded.json` | Human-readable decoded header + payload | +| `.evidence-vp.jwt` | Evidence VP as signed JWS (if credential has evidence) | +| `.evidence-vp.decoded.json` | Decoded evidence VP | + +### DID documents (`did-webs/`) + +| File | Actor | Method | +|------|-------|--------| +| [`harbour-trust-anchor.did.json`](did-webs/harbour-trust-anchor.did.json) | Harbour Trust Anchor | `did:webs` | +| [`harbour-signing-service.did.json`](did-webs/harbour-signing-service.did.json) | Harbour Signing Service | `did:webs` | +| [`legal-person-0aa6d7ea-...did.json`](did-webs/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json) | Example Corporation GmbH | `did:webs` | +| [`natural-person-550e8400-...did.json`](did-webs/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json) | Alice Smith | `did:webs` | + +## Regenerating Signed Examples + +```bash +source .venv/bin/activate +PYTHONPATH=src/python:$PYTHONPATH python -m credentials.example_signer examples/ +``` + +This signs all `examples/*.json` files and writes artifacts to `examples/signed/`. + +> **Wire format vs JSON-LD**: The `.json` files in this directory show credentials +> as expanded JSON-LD for readability. On the wire, every credential and VP is +> encoded as a VC-JOSE-COSE compact JWS (`typ: vc+jwt` or `vp+jwt`) +> signed with ES256 (P-256). The `.jwt` files contain the actual wire format. + +## Related Documentation + +- [Evidence types](../docs/guide/evidence.md) — CredentialEvidence + DelegatedSignatureEvidence +- [Delegated signing flow](../docs/guide/delegated-signing.md) — Complete OID4VP consent flow +- [Delegation challenge spec](../docs/specs/delegation-challenge-encoding.md) — Challenge format + transaction data +- [DID documents](did-webs/README.md) — All example `did:webs` identifiers diff --git a/examples/consent-vp.json b/examples/consent-vp.json deleted file mode 100644 index 40dc445..0000000 --- a/examples/consent-vp.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/ns/credentials/v2" - ], - "type": [ - "VerifiablePresentation" - ], - "holder": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", - "verifiableCredential": [ - { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/reachhaven/harbour/credentials/v1/" - ], - "type": [ - "VerifiableCredential", - "harbour:NaturalPersonCredential" - ], - "issuer": "did:web:issuer.example.com", - "validFrom": "2024-01-15T00:00:00Z", - "credentialSubject": { - "id": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", - "type": "harbour:NaturalPerson", - "name": "Alice Smith", - "memberOf": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" - } - } - ], - "evidence": [ - { - "type": "harbour:DelegatedSignatureEvidence", - "transaction_data": { - "type": "harbour_delegate:data.purchase", - "credential_ids": [ - "simpulse_id" - ], - "transaction_data_hashes_alg": [ - "sha-256" - ], - "nonce": "da9b1009", - "iat": 1771934400, - "txn": { - "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", - "price": "100", - "currency": "ENVITED", - "marketplace": "did:web:dataspace.envited.io" - } - }, - "challenge": "da9b1009 HARBOUR_DELEGATE c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f", - "delegatedTo": "did:web:signing-service.envited.io" - } - ] -} diff --git a/examples/delegated-signing-receipt.json b/examples/delegated-signing-receipt.json index 5fceaec..064f9a3 100644 --- a/examples/delegated-signing-receipt.json +++ b/examples/delegated-signing-receipt.json @@ -8,17 +8,17 @@ "harbour:DelegatedSigningReceipt" ], "id": "urn:uuid:f7e8d9c0-b1a2-3456-7890-abcdef012345", - "issuer": "did:web:signing-service.envited.io", + "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", "validFrom": "2025-06-25T10:00:00Z", "credentialSubject": { "id": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", "type": "harbour:TransactionReceipt", - "transactionHash": "c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f", + "transactionHash": "cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52", "blockchainTxId": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" }, "credentialStatus": [ { - "id": "did:web:signing-service.envited.io:services:revocation-registry#f7e8d9c0b1a23456", + "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry#f7e8d9c0b1a23456", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -33,7 +33,7 @@ "type": [ "VerifiablePresentation" ], - "holder": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", + "holder": "did:jwk:eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6IjZka1U2Wk1GSzc5V3dpY3dKNXJieEUxM3pTdWtCWTJPb0VpVlVFanFNRWMiLCJ5IjoiUm5Iem55VmxyUFNNVDdpckRzMTVEOXd4Z01vamlTREFRcGZGaHFUa0xSWSJ9", "verifiableCredential": [ { "@context": [ @@ -44,20 +44,20 @@ "VerifiableCredential", "harbour:NaturalPersonCredential" ], - "issuer": "did:web:issuer.example.com", + "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", "credentialSubject": { "id": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", "type": "harbour:NaturalPerson", - "memberOf": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" + "memberOf": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" } } ] }, - "delegatedTo": "did:web:signing-service.envited.io", + "delegatedTo": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", "transaction_data": { "type": "harbour_delegate:data.purchase", "credential_ids": [ - "simpulse_id" + "harbour_natural_person" ], "transaction_data_hashes_alg": [ "sha-256" @@ -71,7 +71,7 @@ "marketplace": "did:web:dataspace.envited.io" } }, - "challenge": "da9b1009 HARBOUR_DELEGATE c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f" + "challenge": "da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52" } ] } diff --git a/examples/did-webs/README.md b/examples/did-webs/README.md index db78fa0..7bd1205 100644 --- a/examples/did-webs/README.md +++ b/examples/did-webs/README.md @@ -11,24 +11,53 @@ used in `examples/*.json`. - Integrators must host corresponding `did.json` and `keri.cesr` resources in production according to the `did:webs` method specification. -## Naming Policy in These Examples +## Naming Policy -- Natural person identifiers use a UUID path segment and do not carry real - names in the DID path. -- Legal person identifiers may use an organization suffix (for example - `bmw_ag`) in the DID path. +All identifiers use **UUID path segments** — never real names, organization names, +or other identifying information in the DID path. This prevents DID IRIs from +leaking identity at the public layer. -## Example IDs +## Credential Issuance Model -- Natural person: - `did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP` -- Legal person: - `did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe` -- EmailPass issuer (legal person): - `did:webs:issuers.altme.example:legal-persons:altme_sas:EMtR9m3wZ5xV2k8sP4jQ7nH1cD6bL0fYgAaUu2hCqK9M` +The Harbour Signing Service is the **sole issuer** of all credentials. It uses +two keys in its DID document: -## EmailPass Modeling +| Key | Relationship | Purpose | +|-----|-------------|---------| +| `#key-1` | `assertionMethod` | Signs all issued credentials | +| `#key-2` | `capabilityDelegation` | Signs delegated blockchain transactions | -EmailPass is modeled as a verifiable credential used inside `CredentialEvidence`. -It is **not** a DID verification relationship. The binding to `did:webs` comes from -the EmailPass VC issuer DID and its DID document in this folder. +Authorization is proven via `CredentialEvidence` VPs: + +- **LegalPersonCredential**: Trust Anchor presents VP with its self-signed + LegalPersonCredential (root of trust, publicly resolvable via + `LinkedCredentialService`). +- **NaturalPersonCredential**: Organization presents VP with its + LegalPersonCredential (SD-JWT, sensitive fields redacted). + +## Example Identities + +### Server-side (Harbour infrastructure) + +| Actor | DID | File | +|-------|-----|------| +| Trust Anchor | `did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo` | `harbour-trust-anchor.did.json` | +| Signing Service | `did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ` | `harbour-signing-service.did.json` | + +### User-side (wallet-registered) + +| Actor | DID | Wallet (`did:jwk`) | File | +|-------|-----|--------------------|------| +| Legal person | `did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe` | `did:jwk:eyJ...vbyJ9` | `legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json` | +| Natural person | `did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP` | `did:jwk:eyJ...TLRY` | `natural-person-550e8400-e29b-41d4-a716-446655440000.did.json` | + +## Trust Anchor Self-Signed Credential + +The Trust Anchor holds a self-signed `LegalPersonCredential` where +`issuer == credentialSubject.id`. This is analogous to a root CA certificate. +It is linked from the Trust Anchor's DID document via a +`harbour:LinkedCredentialService` service endpoint, making it publicly resolvable. + +See [`../trust-anchor-credential.json`](../trust-anchor-credential.json). + +See [`../README.md`](../README.md) for the complete user journey. diff --git a/examples/did-webs/harbour-signing-service.did.json b/examples/did-webs/harbour-signing-service.did.json new file mode 100644 index 0000000..6f4d5d5 --- /dev/null +++ b/examples/did-webs/harbour-signing-service.did.json @@ -0,0 +1,42 @@ +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/jwk/v1" + ], + "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "controller": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "verificationMethod": [ + { + "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ#key-1", + "type": "JsonWebKey2020", + "controller": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "W3OIQrEY5e5WOLMSqo82WIiKnNS3YZmCwazJ5jCReGk", + "y": "D562mZty35hWJ2V6rKQ5N5IJOKpZkVL52ucujzNcMI8" + } + }, + { + "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ#key-2", + "type": "JsonWebKey2020", + "controller": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "aBzuAd1MlQAeJxCitiz-AqlMgsw1K1L_tuJ8u8hptxY", + "y": "ZRY5BsEos8y7QP4hKstg7uzFjduqq_zZlyFIES6Kagk" + } + } + ], + "authentication": [ + "#key-1", + "#key-2" + ], + "assertionMethod": [ + "#key-1" + ], + "capabilityDelegation": [ + "#key-2" + ] +} diff --git a/examples/did-webs/harbour-trust-anchor.did.json b/examples/did-webs/harbour-trust-anchor.did.json new file mode 100644 index 0000000..dd529d1 --- /dev/null +++ b/examples/did-webs/harbour-trust-anchor.did.json @@ -0,0 +1,34 @@ +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/jwk/v1" + ], + "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "controller": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "verificationMethod": [ + { + "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo#key-1", + "type": "JsonWebKey2020", + "controller": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "aBzuAd1MlQAeJxCitiz-AqlMgsw1K1L_tuJ8u8hptxY", + "y": "ZRY5BsEos8y7QP4hKstg7uzFjduqq_zZlyFIES6Kagk" + } + } + ], + "authentication": [ + "#key-1" + ], + "assertionMethod": [ + "#key-1" + ], + "service": [ + { + "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo#linked-credential", + "type": "harbour:LinkedCredentialService", + "serviceEndpoint": "https://reachhaven.com/.well-known/credentials/trust-anchor.jwt" + } + ] +} diff --git a/examples/did-webs/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json b/examples/did-webs/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json new file mode 100644 index 0000000..f2e26c3 --- /dev/null +++ b/examples/did-webs/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json @@ -0,0 +1,27 @@ +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/jwk/v1" + ], + "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "controller": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "verificationMethod": [ + { + "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe#key-1", + "type": "JsonWebKey2020", + "controller": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBL", + "y": "weN12rGDez3FP0HRkGbMEMN6YPf7rMBMcxmvIRbJboo" + } + } + ], + "authentication": [ + "#key-1" + ], + "assertionMethod": [ + "#key-1" + ] +} diff --git a/examples/did-webs/legal-person-altme_sas.did.json b/examples/did-webs/legal-person-altme_sas.did.json deleted file mode 100644 index 7faee09..0000000 --- a/examples/did-webs/legal-person-altme_sas.did.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/jwk/v1" - ], - "id": "did:webs:issuers.altme.example:legal-persons:altme_sas:EMtR9m3wZ5xV2k8sP4jQ7nH1cD6bL0fYgAaUu2hCqK9M", - "controller": "did:webs:issuers.altme.example:legal-persons:altme_sas:EMtR9m3wZ5xV2k8sP4jQ7nH1cD6bL0fYgAaUu2hCqK9M", - "verificationMethod": [ - { - "id": "did:webs:issuers.altme.example:legal-persons:altme_sas:EMtR9m3wZ5xV2k8sP4jQ7nH1cD6bL0fYgAaUu2hCqK9M#key-1", - "type": "JsonWebKey2020", - "controller": "did:webs:issuers.altme.example:legal-persons:altme_sas:EMtR9m3wZ5xV2k8sP4jQ7nH1cD6bL0fYgAaUu2hCqK9M", - "publicKeyJwk": { - "kty": "EC", - "crv": "P-256", - "x": "6IV8gKy5sZtpBj0cmcLMvvYQElrmSMcZ1j3YyOb_KwQ", - "y": "bDwerXTq4pnsPuhO0RFYhmouOeF0p8FdSBcwzMpvGU0" - } - } - ], - "authentication": [ - "#key-1" - ], - "assertionMethod": [ - "#key-1" - ] -} diff --git a/examples/did-webs/legal-person-bmw_ag.did.json b/examples/did-webs/legal-person-bmw_ag.did.json deleted file mode 100644 index 19dd5f4..0000000 --- a/examples/did-webs/legal-person-bmw_ag.did.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/jwk/v1" - ], - "id": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", - "controller": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", - "verificationMethod": [ - { - "id": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe#key-1", - "type": "JsonWebKey2020", - "controller": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", - "publicKeyJwk": { - "kty": "EC", - "crv": "P-256", - "x": "6IV8gKy5sZtpBj0cmcLMvvYQElrmSMcZ1j3YyOb_KwQ", - "y": "bDwerXTq4pnsPuhO0RFYhmouOeF0p8FdSBcwzMpvGU0" - } - } - ], - "authentication": [ - "#key-1" - ], - "assertionMethod": [ - "#key-1" - ] -} diff --git a/examples/did-webs/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json b/examples/did-webs/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json index c4fbabb..04c8957 100644 --- a/examples/did-webs/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json +++ b/examples/did-webs/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json @@ -13,8 +13,8 @@ "publicKeyJwk": { "kty": "EC", "crv": "P-256", - "x": "6IV8gKy5sZtpBj0cmcLMvvYQElrmSMcZ1j3YyOb_KwQ", - "y": "bDwerXTq4pnsPuhO0RFYhmouOeF0p8FdSBcwzMpvGU0" + "x": "6dkU6ZMFK79WwicwJ5rbxE13zSukBY2OoEiVUEjqMEc", + "y": "RnHznyVlrPSMT7irDs15D9wxgMojiSDAQpfFhqTkLRY" } } ], diff --git a/examples/legal-person-credential.json b/examples/legal-person-credential.json index a6e67bd..08d526b 100644 --- a/examples/legal-person-credential.json +++ b/examples/legal-person-credential.json @@ -9,17 +9,20 @@ "harbour:LegalPersonCredential" ], "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "issuer": "did:web:trust-anchor.example.com", + "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", "validFrom": "2024-01-15T00:00:00Z", "validUntil": "2025-01-15T00:00:00Z", "credentialSubject": { - "id": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", "type": "harbour:LegalPerson", "name": "Example Corporation GmbH", "gxParticipant": { "type": "gx:LegalPerson", - "gx:legalName": "Example Corporation GmbH", - "gx:registrationNumber": "DE123456789", + "schema:name": "Example Corporation GmbH", + "gx:registrationNumber": { + "type": "gx:RegistrationNumber", + "schema:taxID": "DE123456789" + }, "gx:headquartersAddress": { "type": "gx:Address", "gx:countryCode": "DE", @@ -36,7 +39,7 @@ }, "credentialStatus": [ { - "id": "did:web:trust-anchor.example.com:services:revocation-registry#a1b2c3d4e5f67890", + "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry#a1b2c3d4e5f67890", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -47,21 +50,27 @@ "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "holder": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", "verifiableCredential": [ { "@context": [ "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/gaia-x/development#" + "https://w3id.org/gaia-x/development#", + "https://w3id.org/reachhaven/harbour/credentials/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:LegalPersonCredential" ], - "type": ["VerifiableCredential"], - "issuer": "did:web:notary.example.com", - "validFrom": "2024-01-10T00:00:00Z", + "issuer": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", "credentialSubject": { - "id": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", - "type": "gx:LegalPerson", - "gx:legalName": "Example Corporation GmbH", - "gx:registrationNumber": "DE123456789" + "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "type": "harbour:LegalPerson", + "name": "ReachHaven GmbH", + "gxParticipant": { + "type": "gx:LegalPerson", + "schema:name": "ReachHaven GmbH" + } } } ] diff --git a/examples/natural-person-credential.json b/examples/natural-person-credential.json index bd50488..0cf3abe 100644 --- a/examples/natural-person-credential.json +++ b/examples/natural-person-credential.json @@ -9,7 +9,7 @@ "harbour:NaturalPersonCredential" ], "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f23456789012", - "issuer": "did:web:issuer.example.com", + "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", "validFrom": "2024-01-15T00:00:00Z", "validUntil": "2025-01-15T00:00:00Z", "credentialSubject": { @@ -19,7 +19,7 @@ "schema:givenName": "Alice", "schema:familyName": "Smith", "schema:email": "alice.smith@example.com", - "memberOf": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "memberOf": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", "gxParticipant": { "type": "gx:Participant", "schema:name": "Alice Smith" @@ -27,7 +27,7 @@ }, "credentialStatus": [ { - "id": "did:web:issuer.example.com:services:revocation-registry#b2c3d4e5f6a78901", + "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry#b2c3d4e5f6a78901", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -38,17 +38,27 @@ "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", + "holder": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", "verifiableCredential": [ { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiableCredential"], - "issuer": "did:webs:issuers.altme.example:legal-persons:altme_sas:EMtR9m3wZ5xV2k8sP4jQ7nH1cD6bL0fYgAaUu2hCqK9M", - "validFrom": "2024-01-10T00:00:00Z", + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#", + "https://w3id.org/reachhaven/harbour/credentials/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:LegalPersonCredential" + ], + "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", "credentialSubject": { - "id": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", - "type": "EmailPass", - "email": "alice.smith@example.com" + "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "type": "harbour:LegalPerson", + "name": "Example Corporation GmbH", + "gxParticipant": { + "type": "gx:LegalPerson", + "schema:name": "Example Corporation GmbH" + } } } ] diff --git a/examples/service-offering-credential.json b/examples/service-offering-credential.json deleted file mode 100644 index 990599c..0000000 --- a/examples/service-offering-credential.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/gaia-x/development#", - "https://w3id.org/reachhaven/harbour/credentials/v1/" - ], - "type": [ - "VerifiableCredential", - "harbour:ServiceOfferingCredential" - ], - "id": "urn:uuid:c3d4e5f6-a7b8-9012-cdef-345678901234", - "issuer": "did:web:provider.example.com", - "validFrom": "2024-02-01T00:00:00Z", - "validUntil": "2025-02-01T00:00:00Z", - "credentialSubject": { - "id": "did:web:provider.example.com:services:data-api", - "type": "harbour:ServiceOffering", - "name": "Example Data API", - "description": "RESTful API for accessing example datasets", - "gxServiceOffering": { - "type": "gx:ServiceOffering", - "gx:providedBy": "did:web:provider.example.com", - "gx:serviceOfferingTermsAndConditions": { - "type": "gx:TermsAndConditions", - "gx:url": "https://provider.example.com/terms", - "gx:hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" - } - } - }, - "credentialStatus": [ - { - "id": "did:web:provider.example.com:services:revocation-registry#c3d4e5f6a7b89012", - "type": "harbour:CRSetEntry", - "statusPurpose": "revocation" - } - ] -} diff --git a/examples/trust-anchor-credential.json b/examples/trust-anchor-credential.json new file mode 100644 index 0000000..46068c4 --- /dev/null +++ b/examples/trust-anchor-credential.json @@ -0,0 +1,46 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#", + "https://w3id.org/reachhaven/harbour/credentials/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:LegalPersonCredential" + ], + "id": "urn:uuid:c4d5e6f7-a8b9-0123-cdef-456789abcdef", + "issuer": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "type": "harbour:LegalPerson", + "name": "ReachHaven GmbH", + "gxParticipant": { + "type": "gx:LegalPerson", + "schema:name": "ReachHaven GmbH", + "gx:registrationNumber": { + "type": "gx:RegistrationNumber", + "schema:taxID": "DE987654321" + }, + "gx:headquartersAddress": { + "type": "gx:Address", + "gx:countryCode": "DE", + "gx:countryName": "Germany", + "vcard:locality": "Berlin" + }, + "gx:legalAddress": { + "type": "gx:Address", + "gx:countryCode": "DE", + "gx:countryName": "Germany", + "vcard:locality": "Berlin" + } + } + }, + "credentialStatus": [ + { + "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo:services:revocation-registry#c4d5e6f7a8b90123", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ] +} diff --git a/linkml/core.yaml b/linkml/core.yaml index d894658..b857ea5 100644 --- a/linkml/core.yaml +++ b/linkml/core.yaml @@ -15,15 +15,21 @@ imports: slots: id: - # Now this will resolve to core:id (https://w3id.org/reachhaven/harbour/core/v1/id) + # Spec: VCDM2 §4.4 — "id" is OPTIONAL on VC/VP/credentialSubject. + # If present, MUST be a URL (incl. DIDs, urn:uuid:). + # Maps to JSON-LD @id. + # Spec: DID-Core §3.1 — DID is a URI scheme. description: The stable identifier for the entity (becomes @id in JSON-LD). identifier: true range: uri - type: - # This remains rdf:type - description: The semantic type of the entity (becomes @type in JSON-LD). - designates_type: true - slot_uri: rdf:type - multivalued: true - range: uri + # NOTE: "type" is intentionally NOT modeled here. + # Spec: VCDM2 §4.5 — "type" MUST be present, maps to @type. Values MUST be + # terms or absolute URL strings resolvable via @context. + # W3C VC v2 context defines "type": "@type", which correctly maps JSON "type" + # to rdf:type IRIs. Declaring a LinkML slot with slot_uri: rdf:type would + # generate a JSON-LD context entry that overrides the W3C alias, turning type + # values into xsd:anyURI literals instead of IRIs. It would also generate + # SHACL sh:property constraints on rdf:type with sh:datatype/sh:nodeKind + # that reject IRI values. SHACL type-based targeting (sh:targetClass) and + # sh:ignoredProperties (rdf:type) handle rdf:type correctly without this. diff --git a/linkml/gaiax-domain.yaml b/linkml/gaiax-domain.yaml index dff046c..d81d20b 100644 --- a/linkml/gaiax-domain.yaml +++ b/linkml/gaiax-domain.yaml @@ -26,6 +26,12 @@ imports: # Composition Slots # ========================================== # These slots link harbour outer nodes to Gaia-X inner blank nodes. +# Design: Gaia-X Trust Framework defines closed SHACL shapes on gx: types. +# Harbour cannot add properties to gx nodes without violating sh:closed. +# Solution: composition — harbour outer node owns harbour properties; +# nested gx blank node carries only gx properties. +# Spec: Gaia-X Trust Framework 22.10 — gx:LegalPerson, gx:Participant, +# gx:ServiceOffering are closed SHACL shapes requiring specific properties. slots: gxParticipant: @@ -51,6 +57,8 @@ classes: # Harbour wraps Gaia-X participant types via composition. # Gaia-X data lives in nested blank nodes (gxParticipant / # gxServiceOffering) to keep gx closed shapes intact. + # Spec: Gaia-X TF — gx:LegalPerson requires gx:registrationNumber (object), + # gx:headquartersAddress, gx:legalAddress. gx:Participant is the base type. LegalPerson: description: > @@ -59,6 +67,9 @@ classes: gx:LegalPerson blank node, keeping the gx closed shape intact. class_uri: harbour:LegalPerson slots: + # Spec: schema.org — schema:name for human-readable organization name. + # NOTE: gx:legalName is NOT a valid gx:LegalPerson property in current + # Gaia-X shapes. Use schema:name on the harbour outer node instead. - name - gxParticipant slot_usage: @@ -77,6 +88,7 @@ classes: name: required: true attributes: + # Spec: schema.org Person vocabulary — givenName, familyName, email. givenName: slot_uri: schema:givenName range: string @@ -86,6 +98,7 @@ classes: email: slot_uri: schema:email range: string + # Spec: schema.org — memberOf relates Person to Organization. memberOf: description: Organization (LegalPerson) the natural person belongs to. slot_uri: schema:memberOf @@ -107,6 +120,11 @@ classes: # ========================================== # 2. CREDENTIAL TYPES # ========================================== + # Spec: VCDM2 §4 — each credential MUST have @context, type, issuer, + # credentialSubject. Harbour profile additionally requires validFrom + # and credentialStatus (inherited from HarbourCredential). + # Spec: SD-JWT-VC draft-14 — uses vct claim instead of type array. + # Harbour claim_mapping.py bridges W3C ↔ SD-JWT-VC formats. LegalPersonCredential: is_a: HarbourCredential @@ -117,6 +135,8 @@ classes: slot_usage: validFrom: required: true + # Evidence REQUIRED: Trust Anchor authorizes issuance via VP containing + # its self-signed LegalPersonCredential (root of trust). evidence: required: true @@ -129,6 +149,8 @@ classes: slot_usage: validFrom: required: true + # Evidence REQUIRED: organization authorizes issuance via VP containing + # its LegalPersonCredential (SD-JWT, sensitive fields redacted). evidence: required: true diff --git a/linkml/harbour.yaml b/linkml/harbour.yaml index c0a1294..b867442 100644 --- a/linkml/harbour.yaml +++ b/linkml/harbour.yaml @@ -26,16 +26,22 @@ imports: slots: # --- DID Slots --- + # Spec: DID-Core §4.2 — controller is a URI or set of URIs. + # In practice always a DID (did:web:..., did:key:..., etc.). controller: slot_uri: https://www.w3.org/ns/did#controller - range: string + range: uri + # Spec: DID-Core §5.4 — serviceEndpoint can be URI, map, or set. serviceEndpoint: slot_uri: https://www.w3.org/ns/did#serviceEndpoint range: Any required: true # --- Revocation Registry (CRSet) Slots --- + # Spec: VCDM2 §4.10 — credentialStatus MUST have id and type; statusPurpose + # defined by BitstringStatusList. SD-JWT-VC uses "status" claim (MUST NOT + # be selectively disclosable, draft-14 §3.2). statusPurpose: slot_uri: cs:statusPurpose range: string @@ -93,22 +99,35 @@ slots: range: string # --- Credential / Evidence Slots --- + # Spec: VCDM2 §5.6 — evidence is OPTIONAL (0..*), each object MUST have type. + # SD-JWT-VC does NOT define evidence; custom claims allowed (draft-14 §11). evidence: slot_uri: cred:evidence range: Evidence multivalued: true required: false + # Spec: VC-JOSE-COSE §6.1 — media types: application/vp+jwt, application/vp+sd-jwt. + # Payload is the full VC JSON-LD as JWT claims set (§3.1). + # SD-JWT VP: RFC 9901 §4.3 — KB-JWT appended after disclosures. verifiablePresentation: description: > A Verifiable Presentation embedded as evidence. In examples this is shown as expanded JSON-LD for readability; on the wire it is encoded - as a VC-JOSE-COSE compact JWS string (typ: vp+ld+jwt) or SD-JWT VP. + as a VC-JOSE-COSE compact JWS string (typ: vp+jwt) or SD-JWT VP. slot_uri: harbour:verifiablePresentation range: Any required: false # --- Delegated Signature Evidence Slots --- + # Spec: OID4VP §5.1 — transaction_data is request param (array of base64url JSON). + # OID4VP §B.3.3 — KB-JWT carries transaction_data_hashes + _alg. + # OID4VP §8.4 — Wallet MUST process each transaction_data object. + # CSC-DM v1.0.0 — signatureRequest triggers OID4VP flow with transaction_data. + # Harbour Delegation Spec §3 — transaction data object with type, credential_ids, + # nonce, iat, txn. Hash computed over canonical JSON (sorted keys, no whitespace). + # NOTE: OID4VP hashes base64url transport string; Harbour hashes decoded canonical + # JSON. Both serve different layers (transport binding vs content integrity). transaction_data: description: > OID4VP-aligned transaction data object (§8.4). Contains action type, @@ -119,6 +138,8 @@ slots: range: Any required: false + # Spec: Conceptually maps to OID4VP client_id → KB-JWT aud (RFC 9901 §4.3) + # or Data Integrity proof.domain (VC-DI §2.1). delegatedTo: description: DID of the signing service executing on behalf of the user. slot_uri: harbour:delegatedTo @@ -126,6 +147,10 @@ slots: required: false # --- Credential Envelope Slots --- + # Spec: VCDM2 §4.7 — issuer MUST exist; MUST be URL or object with id. + # VC-JOSE §3.1.3 — iss SHOULD NOT conflict with issuer. + # SD-JWT-VC draft-14 — iss OPTIONAL (can use x5c); draft-08 REQUIRED. + # Harbour profile: REQUIRED (stricter than SD-JWT-VC draft-14). issuer: slot_uri: cred:issuer range: string @@ -133,11 +158,16 @@ slots: description: DID of the credential issuer. # --- W3C VC v2 Envelope Slots (harbour constrains these) --- + # Spec: VCDM2 §4.9 — validFrom is OPTIONAL, xsd:dateTime with mandatory TZ. + # SD-JWT-VC maps to iat (OPTIONAL, selectively disclosable) or nbf (OPTIONAL). + # Harbour profile: REQUIRED (stricter than base spec). validFrom: slot_uri: cred:validFrom range: datetime required: true + # Spec: VCDM2 §4.9 — validUntil is OPTIONAL, xsd:dateTime. + # SD-JWT-VC maps to exp (OPTIONAL, MUST NOT be selectively disclosable). validUntil: slot_uri: cred:validUntil range: datetime @@ -151,17 +181,24 @@ classes: # ========================================== # 1. ROOT DOCUMENT # ========================================== + # Spec: DID-Core §4 — DID Document is a set of data describing the DID subject. + # Properties: id (REQUIRED), controller, verificationMethod, service, etc. DIDDocument: class_uri: https://www.w3.org/ns/did#DIDDocument slots: - id - controller attributes: + # Spec: DID-Core §5.4 — service is OPTIONAL, each entry MUST have id, type, + # and serviceEndpoint. service values MUST be unique. service: slot_uri: https://www.w3.org/ns/did#service multivalued: true inlined: true range: ServiceUnion + # Spec: DID-Core §5.3.1 — verificationMethod entries MUST have id, type, + # controller, and key material (publicKeyJwk or publicKeyMultibase). + # Harbour models a subset (id, controller, blockchainAccountId). verificationMethod: slot_uri: https://www.w3.org/ns/did#verificationMethod multivalued: true @@ -170,22 +207,41 @@ classes: # ========================================== # 2. SERVICES # ========================================== + # Spec: DID-Core §5.4 — services express ways of communicating with the DID + # subject. Each service MUST have id, type, serviceEndpoint. ServiceUnion: union_of: - TrustAnchorService - CRSetRevocationRegistryService + - LinkedCredentialService + # Harbour-specific: trust anchor endpoint exposing organization metadata. TrustAnchorService: class_uri: harbour:TrustAnchorService slots: - id - - type - serviceEndpoint slot_usage: serviceEndpoint: range: OrganizationEndpoint inlined: true + # Harbour-specific: linked credential endpoint for self-signed root credentials. + # Analogous to a root CA certificate — the Trust Anchor's self-signed + # LegalPersonCredential is publicly resolvable via this service endpoint. + LinkedCredentialService: + class_uri: harbour:LinkedCredentialService + slots: + - id + - serviceEndpoint + slot_usage: + serviceEndpoint: + range: uri + description: > + HTTPS URL where the self-signed credential (VC-JOSE-COSE JWT) can + be fetched. Typically a .well-known path on the Trust Anchor's domain. + + # Uses schema.org Organization vocabulary for interoperability. OrganizationEndpoint: class_uri: schema:Organization slots: @@ -204,11 +260,13 @@ classes: - contactType - email + # Harbour-specific: CRSet revocation registry service endpoint. + # Spec: VCDM2 §4.10 — credentialStatus mechanisms are extensible. CRSet is a + # Harbour-defined mechanism (not BitstringStatusList or StatusList2021). CRSetRevocationRegistryService: class_uri: harbour:CRSetRevocationRegistryService slots: - id - - type - serviceEndpoint slot_usage: serviceEndpoint: @@ -227,8 +285,14 @@ classes: # ========================================== # 3. CREDENTIAL TYPES # ========================================== + # Spec: VCDM2 §4 — VerifiableCredential MUST have @context, type, + # credentialSubject, issuer. MAY have id, validFrom, validUntil, + # credentialStatus, evidence, credentialSchema, relatedResource, etc. # Harbour credential types add mandatory credentialStatus (CRSetEntry) # and optional evidence to W3C VerifiableCredential. + # Spec: VC-JOSE §3.1.1 — full VC JSON-LD becomes JWT payload (no vc wrapper). + # Spec: SD-JWT-VC draft-14 §11 — SD-JWT-VC does NOT use W3C VCDM; flat claims. + # Harbour claim_mapping.py bridges W3C ↔ SD-JWT-VC formats. HarbourCredential: abstract: true @@ -238,12 +302,16 @@ classes: class_uri: harbour:HarbourCredential slots: - id - - type - issuer - validFrom - validUntil - evidence attributes: + # Spec: VCDM2 §4.10 — credentialStatus is OPTIONAL, each MUST have type. + # Harbour profile: REQUIRED (stricter than base spec). + # SD-JWT-VC uses "status" claim (MUST NOT be selectively disclosable, + # draft-14 §3.2). CRSet is a Harbour-defined status type; VCDM2 allows + # custom types provided they have id and type. credentialStatus: slot_uri: cred:credentialStatus range: CRSetEntry @@ -254,18 +322,29 @@ classes: # ========================================== # 4. EVIDENCE TYPES # ========================================== + # Spec: VCDM2 §5.6 — evidence is OPTIONAL (0..*), each object MUST have type. + # No specific evidence subtypes are defined by the base spec. Evidence: abstract: true class_uri: cred:Evidence - slots: - - type + # Harbour-specific evidence type for authorization proof during issuance. + # Spec: VCDM2 §5.6 — evidence can contain any claims (extensible). + # Spec: VC-JOSE §6.1 — embedded VP is application/vp+jwt or application/vp+sd-jwt. CredentialEvidence: is_a: Evidence description: > - Evidence that the issuer verified claims using a prior credential - or verifiable presentation. The embedded VP contains the credentials - the issuer relied upon (e.g., email verification, notary attestation). + Evidence that an authorizing party approved the credential issuance + via OID4VP. The embedded VP carries the authorization proof: + (1) For LegalPersonCredential: the Trust Anchor presents a VP + containing its self-signed LegalPersonCredential, authorizing + the Signing Service to issue a credential for the organization. + (2) For NaturalPersonCredential: the organization presents a VP + containing its own LegalPersonCredential (SD-JWT with sensitive + fields redacted), authorizing the Signing Service to issue a + credential for the employee. + The Signing Service is the sole issuer of all credentials; + evidence VPs establish the chain of authorization. class_uri: harbour:CredentialEvidence slots: - verifiablePresentation @@ -273,6 +352,14 @@ classes: verifiablePresentation: required: true + # Harbour-specific evidence type for delegated signing flows. + # Spec: OID4VP §5.1, §8.4 — transaction_data in authorization request. + # Spec: OID4VP §B.3.3 — KB-JWT carries transaction_data_hashes + _alg. + # Spec: CSC-DM v1.0.0 — signatureRequest triggers OID4VP flow. + # Spec: Harbour Delegation Spec §3 — challenge = " HARBOUR_DELEGATE ". + # transaction_data contains type, credential_ids, nonce, iat, txn. + # Spec: OID4VP §B.1.3.2.5 — Data Integrity proof.challenge = OID4VP nonce; + # proof.domain = OID4VP client_id. KB-JWT equivalent: nonce = nonce, aud = client_id. DelegatedSignatureEvidence: is_a: Evidence description: > @@ -293,21 +380,27 @@ classes: transaction_data: required: true + # Spec: VCDM2 §4.10 — credentialStatus entries MUST have id and type. + # CRSet is a Harbour-defined status type (not BitstringStatusListEntry). + # SD-JWT-VC uses "status" with status_list sub-object (idx + uri). CRSetEntry: class_uri: harbour:CRSetEntry slots: - id - - type - statusPurpose # ========================================== # 5. HELPERS # ========================================== + # Spec: DID-Core §5.3.1 — verificationMethod MUST have id, type, controller, + # and key material (publicKeyJwk or publicKeyMultibase). Harbour models a + # subset; blockchainAccountId is a Harbour extension for on-chain binding. + # Spec: VC-JOSE §4.2 — verification method type MUST be JsonWebKey; key + # material MUST be in publicKeyJwk property. VerificationMethod: class_uri: https://www.w3.org/ns/did#VerificationMethod slots: - id - - type - controller attributes: blockchainAccountId: diff --git a/src/python/credentials/claim_mapping.py b/src/python/credentials/claim_mapping.py index d5eb294..14f70e6 100644 --- a/src/python/credentials/claim_mapping.py +++ b/src/python/credentials/claim_mapping.py @@ -32,7 +32,7 @@ "vct": f"{HARBOUR_NS}LegalPersonCredential", "claims": { "credentialSubject.name": "name", - "credentialSubject.gxParticipant.gx:legalName": "legalName", + "credentialSubject.gxParticipant.schema:name": "legalName", "credentialSubject.gxParticipant.gx:registrationNumber": "registrationNumber", "credentialSubject.gxParticipant.gx:headquartersAddress": "headquartersAddress", "credentialSubject.gxParticipant.gx:legalAddress": "legalAddress", diff --git a/src/python/harbour/kb_jwt.py b/src/python/harbour/kb_jwt.py index c1b057b..41f0945 100644 --- a/src/python/harbour/kb_jwt.py +++ b/src/python/harbour/kb_jwt.py @@ -52,10 +52,15 @@ def create_kb_jwt( """ alg = _resolve_alg(holder_private_key, None) - # Compute sd_hash (SHA-256 of the issuer-jwt part) - issuer_jwt = sd_jwt.split(SD_JWT_SEPARATOR)[0] + # Compute sd_hash per RFC 9901 §4.3.1 — hash over the entire SD-JWT + # string before the KB-JWT: ~~...~~ + sd_jwt_for_hash = ( + sd_jwt if sd_jwt.endswith(SD_JWT_SEPARATOR) else sd_jwt + SD_JWT_SEPARATOR + ) sd_hash = ( - base64.urlsafe_b64encode(hashlib.sha256(issuer_jwt.encode("ascii")).digest()) + base64.urlsafe_b64encode( + hashlib.sha256(sd_jwt_for_hash.encode("ascii")).digest() + ) .rstrip(b"=") .decode() ) @@ -154,10 +159,11 @@ def verify_kb_jwt( f"got {payload.get('aud')!r}" ) - # Verify sd_hash - issuer_jwt = parts[0] + # Verify sd_hash per RFC 9901 §4.3.1 — hash over everything before KB-JWT: + # ~~...~~ + sd_jwt_part = SD_JWT_SEPARATOR.join(parts[:-1]) + SD_JWT_SEPARATOR expected_sd_hash = ( - base64.urlsafe_b64encode(hashlib.sha256(issuer_jwt.encode("ascii")).digest()) + base64.urlsafe_b64encode(hashlib.sha256(sd_jwt_part.encode("ascii")).digest()) .rstrip(b"=") .decode() ) diff --git a/src/python/harbour/sd_jwt_vp.py b/src/python/harbour/sd_jwt_vp.py index 0f0df13..0ba4c9e 100644 --- a/src/python/harbour/sd_jwt_vp.py +++ b/src/python/harbour/sd_jwt_vp.py @@ -296,16 +296,17 @@ def issue_sd_jwt_vp( vp_jwt = jws.serialize_compact(vp_header, vp_payload_bytes, key, algorithms=[alg]) # Create KB-JWT for holder binding + # RFC 9901 §4.3.1 — sd_hash over ~~...~~ + sd_material = ( + issuer_jwt + + SD_JWT_SEPARATOR + + SD_JWT_SEPARATOR.join(selected_disclosures) + + SD_JWT_SEPARATOR + ) kb_payload = { "iat": int(time.time()), "sd_hash": base64.urlsafe_b64encode( - hashlib.sha256( - ( - issuer_jwt - + SD_JWT_SEPARATOR - + SD_JWT_SEPARATOR.join(selected_disclosures) - ).encode("ascii") - ).digest() + hashlib.sha256(sd_material.encode("ascii")).digest() ) .rstrip(b"=") .decode(), @@ -429,7 +430,13 @@ def verify_sd_jwt_vp( raise VerificationError("VC hash mismatch: VP does not bind to presented VC") # 5. Verify SD hash in KB-JWT - sd_material = issuer_jwt + SD_JWT_SEPARATOR + SD_JWT_SEPARATOR.join(disclosures) + # RFC 9901 §4.3.1 — sd_hash over ~~...~~ + sd_material = ( + issuer_jwt + + SD_JWT_SEPARATOR + + SD_JWT_SEPARATOR.join(disclosures) + + SD_JWT_SEPARATOR + ) expected_sd_hash = ( base64.urlsafe_b64encode(hashlib.sha256(sd_material.encode("ascii")).digest()) .rstrip(b"=") diff --git a/src/python/harbour/signer.py b/src/python/harbour/signer.py index 1cad361..f95c8e0 100644 --- a/src/python/harbour/signer.py +++ b/src/python/harbour/signer.py @@ -39,7 +39,7 @@ def sign_vc_jose( Compact JWS string (header.payload.signature). """ alg = _resolve_alg(private_key, alg) - header = _build_header(alg, typ="vc+ld+jwt", kid=kid, x5c=x5c) + header = _build_header(alg, typ="vc+jwt", kid=kid, x5c=x5c) payload = json.dumps(vc, ensure_ascii=False).encode("utf-8") key = _import_private_key(private_key, alg) return jws.serialize_compact(header, payload, key, algorithms=[alg]) @@ -68,7 +68,7 @@ def sign_vp_jose( Compact JWS string (header.payload.signature). """ alg = _resolve_alg(private_key, alg) - header = _build_header(alg, typ="vp+ld+jwt", kid=kid) + header = _build_header(alg, typ="vp+jwt", kid=kid) # Add nonce and audience to the VP payload (not header) vp_payload = dict(vp) diff --git a/src/python/harbour/verifier.py b/src/python/harbour/verifier.py index c23d24e..51f76d2 100644 --- a/src/python/harbour/verifier.py +++ b/src/python/harbour/verifier.py @@ -35,7 +35,7 @@ def verify_vc_jose(token: str, public_key: PublicKeyType) -> dict: Raises: VerificationError: If the signature is invalid or the token is malformed. """ - return _verify_jose(token, public_key, expected_typ="vc+ld+jwt") + return _verify_jose(token, public_key, expected_typ="vc+jwt") def verify_vp_jose( @@ -59,7 +59,7 @@ def verify_vp_jose( Raises: VerificationError: If the signature, nonce, or audience is invalid. """ - payload = _verify_jose(token, public_key, expected_typ="vp+ld+jwt") + payload = _verify_jose(token, public_key, expected_typ="vp+jwt") if expected_nonce is not None: actual_nonce = payload.get("nonce") diff --git a/src/typescript/harbour/kb-jwt.ts b/src/typescript/harbour/kb-jwt.ts index 58ee988..9a3f5c3 100644 --- a/src/typescript/harbour/kb-jwt.ts +++ b/src/typescript/harbour/kb-jwt.ts @@ -42,10 +42,13 @@ export async function createKbJwt( ): Promise { const { nonce, audience, transaction_data } = options; - // Compute sd_hash (SHA-256 of the issuer-jwt part) - const issuerJwt = sdJwt.split(SD_JWT_SEPARATOR)[0]; - const issuerJwtBytes = new TextEncoder().encode(issuerJwt); - const hashBuffer = await crypto.subtle.digest("SHA-256", issuerJwtBytes); + // Compute sd_hash per RFC 9901 §4.3.1 — hash over the entire SD-JWT + // string before the KB-JWT: ~~...~~ + const sdJwtForHash = sdJwt.endsWith(SD_JWT_SEPARATOR) + ? sdJwt + : sdJwt + SD_JWT_SEPARATOR; + const sdJwtBytes = new TextEncoder().encode(sdJwtForHash); + const hashBuffer = await crypto.subtle.digest("SHA-256", sdJwtBytes); const sdHash = base64urlEncode(new Uint8Array(hashBuffer)); // Build KB-JWT payload @@ -155,10 +158,15 @@ export async function verifyKbJwt( ); } - // Verify sd_hash - const issuerJwt = parts[0]; - const issuerJwtBytes = new TextEncoder().encode(issuerJwt); - const expectedHashBuffer = await crypto.subtle.digest("SHA-256", issuerJwtBytes); + // Verify sd_hash per RFC 9901 §4.3.1 — hash over everything before KB-JWT: + // ~~...~~ + const sdJwtPart = + parts.slice(0, -1).join(SD_JWT_SEPARATOR) + SD_JWT_SEPARATOR; + const sdJwtPartBytes = new TextEncoder().encode(sdJwtPart); + const expectedHashBuffer = await crypto.subtle.digest( + "SHA-256", + sdJwtPartBytes + ); const expectedSdHash = base64urlEncode(new Uint8Array(expectedHashBuffer)); if (payload.sd_hash !== expectedSdHash) { diff --git a/src/typescript/harbour/sd-jwt-vp.ts b/src/typescript/harbour/sd-jwt-vp.ts index 90e0142..8adab04 100644 --- a/src/typescript/harbour/sd-jwt-vp.ts +++ b/src/typescript/harbour/sd-jwt-vp.ts @@ -171,10 +171,12 @@ export async function issueSdJwtVp( const vpJwt = await vpSigner.sign(holderPrivateKey); // Create KB-JWT + // RFC 9901 §4.3.1 — sd_hash over ~~...~~ const sdMaterial = issuerJwt + SD_JWT_SEPARATOR + - selectedDisclosures.join(SD_JWT_SEPARATOR); + selectedDisclosures.join(SD_JWT_SEPARATOR) + + SD_JWT_SEPARATOR; const sdHashBuffer = await crypto.subtle.digest( "SHA-256", new TextEncoder().encode(sdMaterial) @@ -303,10 +305,12 @@ export async function verifySdJwtVp( } // 5. Verify SD hash in KB-JWT + // RFC 9901 §4.3.1 — sd_hash over ~~...~~ const sdMaterial = issuerJwt + SD_JWT_SEPARATOR + - disclosures.join(SD_JWT_SEPARATOR); + disclosures.join(SD_JWT_SEPARATOR) + + SD_JWT_SEPARATOR; const expectedSdHash = base64urlEncode( new Uint8Array( await crypto.subtle.digest( diff --git a/src/typescript/harbour/signer.ts b/src/typescript/harbour/signer.ts index ba8cf9c..a20aac2 100644 --- a/src/typescript/harbour/signer.ts +++ b/src/typescript/harbour/signer.ts @@ -38,7 +38,7 @@ export async function signVcJose( const payload = new TextEncoder().encode(JSON.stringify(vc)); const signer = new CompactSign(payload); - const header: Record = { alg, typ: "vc+ld+jwt" }; + const header: Record = { alg, typ: "vc+jwt" }; if (options.kid) header.kid = options.kid; if (options.x5c) header.x5c = options.x5c; signer.setProtectedHeader(header as jose.CompactJWSHeaderParameters); @@ -69,7 +69,7 @@ export async function signVpJose( const payload = new TextEncoder().encode(JSON.stringify(vpPayload)); const signer = new CompactSign(payload); - const header: Record = { alg, typ: "vp+ld+jwt" }; + const header: Record = { alg, typ: "vp+jwt" }; if (options.kid) header.kid = options.kid; signer.setProtectedHeader(header as jose.CompactJWSHeaderParameters); diff --git a/src/typescript/harbour/verifier.ts b/src/typescript/harbour/verifier.ts index 87ef32d..53c7081 100644 --- a/src/typescript/harbour/verifier.ts +++ b/src/typescript/harbour/verifier.ts @@ -25,7 +25,7 @@ export async function verifyVcJose( token: string, publicKey: CryptoKey, ): Promise> { - return verifyJose(token, publicKey, "vc+ld+jwt"); + return verifyJose(token, publicKey, "vc+jwt"); } /** @@ -36,7 +36,7 @@ export async function verifyVpJose( publicKey: CryptoKey, options: VpVerifyOptions = {}, ): Promise> { - const payload = await verifyJose(token, publicKey, "vp+ld+jwt"); + const payload = await verifyJose(token, publicKey, "vp+jwt"); if (options.expectedNonce !== undefined) { if (payload.nonce !== options.expectedNonce) { diff --git a/tests/fixtures/canonicalization-vectors.json b/tests/fixtures/canonicalization-vectors.json index 4c9d34c..878d997 100644 --- a/tests/fixtures/canonicalization-vectors.json +++ b/tests/fixtures/canonicalization-vectors.json @@ -6,7 +6,7 @@ "input": { "type": "harbour_delegate:data.purchase", "credential_ids": [ - "simpulse_id" + "harbour_natural_person" ], "transaction_data_hashes_alg": [ "sha-256" @@ -20,11 +20,11 @@ "marketplace": "did:web:dataspace.envited.io" } }, - "canonical_json": "{\"credential_ids\":[\"simpulse_id\"],\"iat\":1771934400,\"nonce\":\"da9b1009\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"asset_id\":\"urn:uuid:550e8400-e29b-41d4-a716-446655440000\",\"currency\":\"ENVITED\",\"marketplace\":\"did:web:dataspace.envited.io\",\"price\":\"100\"},\"type\":\"harbour_delegate:data.purchase\"}", - "sha256_hash": "c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f", - "challenge": "da9b1009 HARBOUR_DELEGATE c0a4f646410379520b80256ca8a9f738d7ce59c9511d24649a452d6e23ea590f", - "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJzaW1wdWxzZV9pZCJdLCJpYXQiOjE3NzE5MzQ0MDAsIm5vbmNlIjoiZGE5YjEwMDkiLCJ0cmFuc2FjdGlvbl9kYXRhX2hhc2hlc19hbGciOlsic2hhLTI1NiJdLCJ0eG4iOnsiYXNzZXRfaWQiOiJ1cm46dXVpZDo1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJjdXJyZW5jeSI6IkVOVklURUQiLCJtYXJrZXRwbGFjZSI6ImRpZDp3ZWI6ZGF0YXNwYWNlLmVudml0ZWQuaW8iLCJwcmljZSI6IjEwMCJ9LCJ0eXBlIjoiaGFyYm91cl9kZWxlZ2F0ZTpkYXRhLnB1cmNoYXNlIn0", - "transaction_data_param_hash": "7W0LFUTpMvb6nJK7ngamNNY0zNvxqJ-2jNXTmLzhWQE" + "canonical_json": "{\"credential_ids\":[\"harbour_natural_person\"],\"iat\":1771934400,\"nonce\":\"da9b1009\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"asset_id\":\"urn:uuid:550e8400-e29b-41d4-a716-446655440000\",\"currency\":\"ENVITED\",\"marketplace\":\"did:web:dataspace.envited.io\",\"price\":\"100\"},\"type\":\"harbour_delegate:data.purchase\"}", + "sha256_hash": "cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52", + "challenge": "da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52", + "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJoYXJib3VyX25hdHVyYWxfcGVyc29uIl0sImlhdCI6MTc3MTkzNDQwMCwibm9uY2UiOiJkYTliMTAwOSIsInRyYW5zYWN0aW9uX2RhdGFfaGFzaGVzX2FsZyI6WyJzaGEtMjU2Il0sInR4biI6eyJhc3NldF9pZCI6InVybjp1dWlkOjU1MGU4NDAwLWUyOWItNDFkNC1hNzE2LTQ0NjY1NTQ0MDAwMCIsImN1cnJlbmN5IjoiRU5WSVRFRCIsIm1hcmtldHBsYWNlIjoiZGlkOndlYjpkYXRhc3BhY2UuZW52aXRlZC5pbyIsInByaWNlIjoiMTAwIn0sInR5cGUiOiJoYXJib3VyX2RlbGVnYXRlOmRhdGEucHVyY2hhc2UifQ", + "transaction_data_param_hash": "b3zwXdmQYj3kdUJLWeHIh_hXDrSucGSTXr4wvob5hqo" }, { "name": "contract.sign \u2014 with optional exp and description", diff --git a/tests/fixtures/sample-vc.json b/tests/fixtures/sample-vc.json index c6c4e37..66a4396 100644 --- a/tests/fixtures/sample-vc.json +++ b/tests/fixtures/sample-vc.json @@ -5,10 +5,10 @@ ], "type": ["VerifiableCredential"], "id": "urn:uuid:576fbefb-35e8-4b71-bb1a-53d1803c86de", - "issuer": "did:web:trust.harbour.example.com", + "issuer": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", "validFrom": "2025-08-06T10:15:22Z", "credentialSubject": { - "id": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", "type": "harbour:LegalPerson", "name": "Example Corporation GmbH" } diff --git a/tests/interop/test_cross_runtime.py b/tests/interop/test_cross_runtime.py index fbbbaf8..d4da410 100644 --- a/tests/interop/test_cross_runtime.py +++ b/tests/interop/test_cross_runtime.py @@ -118,7 +118,7 @@ def test_vc_jose(self, p256_public_key): const key = await importJWK(jwk, "ES256"); const payload = new TextEncoder().encode(JSON.stringify({json.dumps(sample_vc)})); const signer = new CompactSign(payload); -signer.setProtectedHeader({{ alg: "ES256", typ: "vc+ld+jwt" }}); +signer.setProtectedHeader({{ alg: "ES256", typ: "vc+jwt" }}); const token = await signer.sign(key); console.log(token); """ @@ -143,7 +143,7 @@ def test_vp_jose(self, p256_public_key): const key = await importJWK(jwk, "ES256"); const payload = new TextEncoder().encode(JSON.stringify({json.dumps(vp)})); const signer = new CompactSign(payload); -signer.setProtectedHeader({{ alg: "ES256", typ: "vp+ld+jwt" }}); +signer.setProtectedHeader({{ alg: "ES256", typ: "vp+jwt" }}); const token = await signer.sign(key); console.log(token); """ diff --git a/tests/python/credentials/test_claim_mapping.py b/tests/python/credentials/test_claim_mapping.py index 9e9e376..cb1f16a 100644 --- a/tests/python/credentials/test_claim_mapping.py +++ b/tests/python/credentials/test_claim_mapping.py @@ -31,10 +31,13 @@ def test_vc_to_claims(self): mapping = MAPPINGS["harbour:LegalPersonCredential"] claims, disclosable = vc_to_sd_jwt_claims(vc, mapping) - assert claims["iss"] == "did:web:trust-anchor.example.com" + assert ( + claims["iss"] + == "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ" + ) assert ( claims["sub"] - == "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" + == "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" ) assert claims["name"] == "Example Corporation GmbH" assert claims["legalName"] == "Example Corporation GmbH" @@ -60,10 +63,10 @@ def test_gx_inner_node_exists(self): subject = vc["credentialSubject"] gx = subject["gxParticipant"] assert gx["type"] == "gx:LegalPerson" - assert "gx:legalName" in gx + assert "schema:name" in gx assert "gx:registrationNumber" in gx # gx properties must NOT be on the outer node - assert "gx:legalName" not in subject + # schema:name is on both nodes (outer via harbour, inner via gx) assert "gx:registrationNumber" not in subject def test_roundtrip(self): @@ -74,8 +77,8 @@ def test_roundtrip(self): claims, mapping, "harbour:LegalPersonCredential" ) assert ( - reconstructed["credentialSubject"]["gxParticipant"]["gx:legalName"] - == vc["credentialSubject"]["gxParticipant"]["gx:legalName"] + reconstructed["credentialSubject"]["gxParticipant"]["schema:name"] + == vc["credentialSubject"]["gxParticipant"]["schema:name"] ) @@ -129,40 +132,6 @@ def test_roundtrip(self): ) -class TestHarbourServiceOfferingMapping: - def test_vc_to_claims(self): - vc = _load_fixture("service-offering-credential.json") - mapping = MAPPINGS["harbour:ServiceOfferingCredential"] - claims, disclosable = vc_to_sd_jwt_claims(vc, mapping) - - assert claims["sub"] == "did:web:provider.example.com:services:data-api" - assert claims["name"] == "Example Data API" - assert claims["providedBy"] == "did:web:provider.example.com" - assert "description" in disclosable - - def test_has_credential_status(self): - vc = _load_fixture("service-offering-credential.json") - assert "credentialStatus" in vc - status = vc["credentialStatus"][0] - assert status["type"] == "harbour:CRSetEntry" - - def test_subject_is_harbour_service_offering(self): - """Verify the subject uses harbour:ServiceOffering (outer node only).""" - vc = _load_fixture("service-offering-credential.json") - subject_type = vc["credentialSubject"]["type"] - assert subject_type == "harbour:ServiceOffering" - - def test_gx_inner_node_exists(self): - """Verify gx:ServiceOffering data lives in the gxServiceOffering inner node.""" - vc = _load_fixture("service-offering-credential.json") - subject = vc["credentialSubject"] - gx = subject["gxServiceOffering"] - assert gx["type"] == "gx:ServiceOffering" - assert "gx:providedBy" in gx - # gx properties must NOT be on the outer node - assert "gx:providedBy" not in subject - - class TestMappingDiscovery: def test_get_mapping_for_harbour_credential(self): vc = _load_fixture("legal-person-credential.json") diff --git a/tests/python/credentials/test_example_signer.py b/tests/python/credentials/test_example_signer.py index 0f9ea85..b15ab73 100644 --- a/tests/python/credentials/test_example_signer.py +++ b/tests/python/credentials/test_example_signer.py @@ -57,7 +57,7 @@ def test_sign_evidence_vp(self, signing_key): vp = { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "holder": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", "verifiableCredential": [ { "@context": ["https://www.w3.org/ns/credentials/v2"], @@ -65,7 +65,7 @@ def test_sign_evidence_vp(self, signing_key): "issuer": "did:web:notary.example.com", "validFrom": "2024-01-10T00:00:00Z", "credentialSubject": { - "id": "did:webs:participants.example.com:legal-persons:bmw_ag:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", "type": "gx:LegalPerson", }, } @@ -113,7 +113,7 @@ def test_decode_evidence_vp(self, signing_key): assert "header" in decoded assert "payload" in decoded - assert decoded["header"]["typ"] == "vp+ld+jwt" + assert decoded["header"]["typ"] == "vp+jwt" inner_vcs = decoded["payload"]["verifiableCredential"] assert len(inner_vcs) == 1 @@ -160,28 +160,6 @@ def test_process_example_with_evidence(self, signing_key, tmp_path): vp_payload = verify_vp_jose(vp_jwt_str, public_key) assert "VerifiablePresentation" in vp_payload["type"] - def test_process_example_without_evidence(self, signing_key, tmp_path): - """Process an example without evidence VP.""" - private_key, public_key, kid = signing_key - - example_path = EXAMPLES_DIR / "service-offering-credential.json" - if not example_path.exists(): - pytest.skip("examples/ not populated") - - output_dir = tmp_path / "signed" - jwt_path = process_example(example_path, private_key, kid, output_dir) - - # Verify output files exist - assert jwt_path.exists() - assert (output_dir / "service-offering-credential.decoded.json").exists() - # No evidence VP files - assert not (output_dir / "service-offering-credential.evidence-vp.jwt").exists() - - # Verify outer VC JWT - vc_jwt = jwt_path.read_text().strip() - vc_payload = verify_vc_jose(vc_jwt, public_key) - assert "harbour:ServiceOfferingCredential" in vc_payload["type"] - def test_process_delegated_signing_receipt(self, signing_key, tmp_path): """Process the delegated signing receipt with DelegatedSignatureEvidence.""" private_key, public_key, kid = signing_key @@ -208,7 +186,10 @@ def test_process_delegated_signing_receipt(self, signing_key, tmp_path): assert evidence["type"] == "harbour:DelegatedSignatureEvidence" assert "transaction_data" in evidence assert evidence["transaction_data"]["type"] == "harbour_delegate:data.purchase" - assert evidence["delegatedTo"] == "did:web:signing-service.envited.io" + assert ( + evidence["delegatedTo"] + == "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ" + ) # Evidence VP should be a signed JWT vp_jwt_str = evidence["verifiablePresentation"] diff --git a/tests/python/harbour/test_delegation.py b/tests/python/harbour/test_delegation.py index 212cb45..809d9bc 100644 --- a/tests/python/harbour/test_delegation.py +++ b/tests/python/harbour/test_delegation.py @@ -89,12 +89,12 @@ def test_create_with_optional_fields(self): txn={"asset_id": "test"}, exp=1771935300, description="Test purchase", - credential_ids=["simpulse_id"], + credential_ids=["harbour_natural_person"], ) assert tx.exp == 1771935300 assert tx.description == "Test purchase" - assert tx.credential_ids == ["simpulse_id"] + assert tx.credential_ids == ["harbour_natural_person"] def test_action_property(self): """Test action extraction from type field.""" @@ -129,7 +129,7 @@ def test_to_dict_includes_optional_when_present(self): """Test TransactionData.to_dict() includes optional fields when set.""" tx = TransactionData( type="harbour_delegate:data.purchase", - credential_ids=["simpulse_id"], + credential_ids=["harbour_natural_person"], nonce="da9b1009", iat=1771934400, txn={"asset_id": "test"}, @@ -903,7 +903,7 @@ def test_full_workflow(self): "currency": "ENVITED", }, description="Purchase sensor data", - credential_ids=["simpulse_id"], + credential_ids=["harbour_natural_person"], ) # 2. Create challenge diff --git a/tests/python/harbour/test_kb_jwt.py b/tests/python/harbour/test_kb_jwt.py index 3993d30..f61c733 100644 --- a/tests/python/harbour/test_kb_jwt.py +++ b/tests/python/harbour/test_kb_jwt.py @@ -11,12 +11,12 @@ from harbour.verifier import VerificationError SAMPLE_CLAIMS = { - "iss": "did:web:did.ascs.digital:participants:ascs", - "legalName": "Bayerische Motoren Werke AG", - "email": "imprint@bmw.com", + "iss": "did:web:issuer.example.com", + "legalName": "Example Corporation GmbH", + "email": "info@example.com", } -VCT = "https://w3id.org/ascs-ev/simpulse-id/credentials/v1/ParticipantCredential" +VCT = "https://w3id.org/reachhaven/harbour/credentials/v1/LegalPersonCredential" @pytest.fixture() diff --git a/tests/python/harbour/test_sd_jwt.py b/tests/python/harbour/test_sd_jwt.py index 7ed76d5..fb3f4a7 100644 --- a/tests/python/harbour/test_sd_jwt.py +++ b/tests/python/harbour/test_sd_jwt.py @@ -10,16 +10,16 @@ from harbour.verifier import VerificationError SAMPLE_CLAIMS = { - "iss": "did:web:did.ascs.digital:participants:ascs", + "iss": "did:web:issuer.example.com", "iat": 1723972522, "exp": 1913990400, - "legalName": "Bayerische Motoren Werke AG", - "legalForm": "AG", + "legalName": "Example Corporation GmbH", + "legalForm": "GmbH", "countryCode": "DE", - "email": "imprint@bmw.com", + "email": "info@example.com", } -VCT = "https://w3id.org/ascs-ev/simpulse-id/credentials/v1/ParticipantCredential" +VCT = "https://w3id.org/reachhaven/harbour/credentials/v1/LegalPersonCredential" class TestSDJWTVCIssuance: @@ -67,8 +67,8 @@ class TestSDJWTVCVerification: def test_verify_all_disclosed(self, p256_private_key, p256_public_key): sd_jwt = issue_sd_jwt_vc(SAMPLE_CLAIMS, p256_private_key, vct=VCT) result = verify_sd_jwt_vc(sd_jwt, p256_public_key) - assert result["legalName"] == "Bayerische Motoren Werke AG" - assert result["iss"] == "did:web:did.ascs.digital:participants:ascs" + assert result["legalName"] == "Example Corporation GmbH" + assert result["iss"] == "did:web:issuer.example.com" def test_verify_with_selective_disclosure(self, p256_private_key, p256_public_key): sd_jwt = issue_sd_jwt_vc( @@ -79,10 +79,10 @@ def test_verify_with_selective_disclosure(self, p256_private_key, p256_public_ke ) result = verify_sd_jwt_vc(sd_jwt, p256_public_key) # Both disclosable claims should be disclosed (all disclosures present) - assert result["email"] == "imprint@bmw.com" + assert result["email"] == "info@example.com" assert result["countryCode"] == "DE" # Non-disclosable claims are always present - assert result["legalName"] == "Bayerische Motoren Werke AG" + assert result["legalName"] == "Example Corporation GmbH" def test_verify_partial_disclosure(self, p256_private_key, p256_public_key): """Remove one disclosure to simulate holder hiding a claim.""" @@ -135,7 +135,7 @@ def test_issue_and_verify_ed25519(self, ed25519_private_key, ed25519_public_key) sd_jwt = issue_sd_jwt_vc(SAMPLE_CLAIMS, ed25519_private_key, vct=VCT) result = verify_sd_jwt_vc(sd_jwt, ed25519_public_key) assert result["vct"] == VCT - assert result["legalName"] == "Bayerische Motoren Werke AG" + assert result["legalName"] == "Example Corporation GmbH" def test_selective_disclosure_ed25519( self, ed25519_private_key, ed25519_public_key @@ -147,7 +147,7 @@ def test_selective_disclosure_ed25519( disclosable=["email", "countryCode"], ) result = verify_sd_jwt_vc(sd_jwt, ed25519_public_key) - assert result["email"] == "imprint@bmw.com" + assert result["email"] == "info@example.com" assert result["countryCode"] == "DE" def test_wrong_key_type_fails(self, ed25519_private_key, p256_public_key): diff --git a/tests/python/harbour/test_sd_jwt_vp.py b/tests/python/harbour/test_sd_jwt_vp.py index 8213499..08f945b 100644 --- a/tests/python/harbour/test_sd_jwt_vp.py +++ b/tests/python/harbour/test_sd_jwt_vp.py @@ -160,7 +160,7 @@ def test_issue_with_evidence(self, sample_sd_jwt_vc, holder_keypair): "type": "DelegatedSignatureEvidence", "transaction_data": { "type": "harbour_delegate:data.purchase", - "credential_ids": ["simpulse_id"], + "credential_ids": ["harbour_natural_person"], "nonce": tx_nonce, "iat": 1771934400, "txn": {"asset_id": "tx:abc123", "price": "100"}, @@ -538,12 +538,12 @@ def test_delegated_consent_flow(self, issuer_keypair, holder_keypair): ) # Step 2: Holder creates consent VP - signing_service_did = "did:web:harbour.signing-service.example.com" + signing_service_did = "did:webs:harbour.signing-service.example.com" consent_nonce = secrets.token_urlsafe(32) transaction_data = { "type": "harbour_delegate:data.purchase", - "credential_ids": ["simpulse_id"], + "credential_ids": ["harbour_natural_person"], "nonce": consent_nonce, "iat": 1771934400, "description": "Purchase data asset XYZ for 100 ENVITED tokens", diff --git a/tests/python/harbour/test_sign.py b/tests/python/harbour/test_sign.py index 8f2febf..c1a1e31 100644 --- a/tests/python/harbour/test_sign.py +++ b/tests/python/harbour/test_sign.py @@ -21,7 +21,7 @@ def test_sign_vc_jose_header_typ(sample_vc, p256_private_key): token = sign_vc_jose(sample_vc, p256_private_key) header = _decode_header(token) assert header["alg"] == "ES256" - assert header["typ"] == "vc+ld+jwt" + assert header["typ"] == "vc+jwt" def test_sign_vc_jose_header_kid(sample_vc, p256_private_key, p256_did_key_vm): @@ -60,7 +60,7 @@ def test_sign_vc_jose_eddsa(sample_vc, ed25519_private_key): token = sign_vc_jose(sample_vc, ed25519_private_key) header = _decode_header(token) assert header["alg"] == "EdDSA" - assert header["typ"] == "vc+ld+jwt" + assert header["typ"] == "vc+jwt" # --------------------------------------------------------------------------- @@ -77,7 +77,7 @@ def test_sign_vp_jose_returns_compact_jws(sample_vp, p256_private_key): def test_sign_vp_jose_header_typ(sample_vp, p256_private_key): token = sign_vp_jose(sample_vp, p256_private_key) header = _decode_header(token) - assert header["typ"] == "vp+ld+jwt" + assert header["typ"] == "vp+jwt" def test_sign_vp_jose_nonce_and_audience(sample_vp, p256_private_key): diff --git a/tests/python/harbour/test_tamper.py b/tests/python/harbour/test_tamper.py index aecb4c8..a6ab6ea 100644 --- a/tests/python/harbour/test_tamper.py +++ b/tests/python/harbour/test_tamper.py @@ -15,7 +15,7 @@ def test_tamper_payload(sample_vc, p256_private_key, p256_public_key): # Decode payload, tamper, re-encode payload = json.loads(base64.urlsafe_b64decode(parts[1] + "==")) - payload["credentialSubject"]["id"] = "did:web:did.ascs.digital:participants:evil" + payload["credentialSubject"]["id"] = "did:web:evil.example.com" tampered_payload = ( base64.urlsafe_b64encode( json.dumps(payload, ensure_ascii=False).encode("utf-8") diff --git a/tests/typescript/harbour/delegation.test.ts b/tests/typescript/harbour/delegation.test.ts index d5250a6..a9da66a 100644 --- a/tests/typescript/harbour/delegation.test.ts +++ b/tests/typescript/harbour/delegation.test.ts @@ -81,12 +81,12 @@ describe("TransactionData", () => { txn: { asset_id: "test" }, exp: 1771935300, description: "Test purchase", - credentialIds: ["simpulse_id"], + credentialIds: ["harbour_natural_person"], }); expect(tx.exp).toBe(1771935300); expect(tx.description).toBe("Test purchase"); - expect(tx.credential_ids).toEqual(["simpulse_id"]); + expect(tx.credential_ids).toEqual(["harbour_natural_person"]); }); it("extracts action from type", () => { diff --git a/tests/typescript/harbour/sd-jwt-vp.test.ts b/tests/typescript/harbour/sd-jwt-vp.test.ts index 5c853ab..1af7c33 100644 --- a/tests/typescript/harbour/sd-jwt-vp.test.ts +++ b/tests/typescript/harbour/sd-jwt-vp.test.ts @@ -127,7 +127,7 @@ describe("issueSdJwtVp", () => { type: "DelegatedSignatureEvidence", transaction_data: { type: "harbour_delegate:data.purchase", - credential_ids: ["simpulse_id"], + credential_ids: ["harbour_natural_person"], nonce: txNonce, iat: 1771934400, txn: { asset_id: "tx:abc123", price: "100" }, diff --git a/tests/typescript/harbour/sd-jwt.test.ts b/tests/typescript/harbour/sd-jwt.test.ts index 0dff9ff..00c5b76 100644 --- a/tests/typescript/harbour/sd-jwt.test.ts +++ b/tests/typescript/harbour/sd-jwt.test.ts @@ -12,15 +12,15 @@ import { const FIXTURES_DIR = resolve(__dirname, "../../fixtures"); const VCT = - "https://w3id.org/ascs-ev/simpulse-id/credentials/v1/ParticipantCredential"; + "https://w3id.org/reachhaven/harbour/credentials/v1/LegalPersonCredential"; const SAMPLE_CLAIMS = { - iss: "did:web:did.ascs.digital:participants:ascs", + iss: "did:web:issuer.example.com", iat: 1723972522, - legalName: "Bayerische Motoren Werke AG", - legalForm: "AG", + legalName: "Example Corporation GmbH", + legalForm: "GmbH", countryCode: "DE", - email: "imprint@bmw.com", + email: "info@example.com", }; let privateKey: CryptoKey; @@ -59,7 +59,7 @@ describe("SD-JWT-VC verification", () => { const sdJwt = await issueSdJwtVc(SAMPLE_CLAIMS, privateKey, { vct: VCT }); const result = await verifySdJwtVc(sdJwt, publicKey); expect(result.vct).toBe(VCT); - expect(result.legalName).toBe("Bayerische Motoren Werke AG"); + expect(result.legalName).toBe("Example Corporation GmbH"); }); it("returns disclosed claims with selective disclosure", async () => { @@ -68,9 +68,9 @@ describe("SD-JWT-VC verification", () => { disclosable: ["email", "countryCode"], }); const result = await verifySdJwtVc(sdJwt, publicKey); - expect(result.email).toBe("imprint@bmw.com"); + expect(result.email).toBe("info@example.com"); expect(result.countryCode).toBe("DE"); - expect(result.legalName).toBe("Bayerische Motoren Werke AG"); + expect(result.legalName).toBe("Example Corporation GmbH"); }); it("throws on wrong key", async () => { diff --git a/tests/typescript/harbour/sign.test.ts b/tests/typescript/harbour/sign.test.ts index bb1b952..faa627b 100644 --- a/tests/typescript/harbour/sign.test.ts +++ b/tests/typescript/harbour/sign.test.ts @@ -47,7 +47,7 @@ describe("signVcJose", () => { const token = await signVcJose(sampleVc, privateKey); const header = decodeHeader(token); expect(header.alg).toBe("ES256"); - expect(header.typ).toBe("vc+ld+jwt"); + expect(header.typ).toBe("vc+jwt"); }); it("includes kid in header when provided", async () => { @@ -80,7 +80,7 @@ describe("signVpJose", () => { it("has correct header typ", async () => { const token = await signVpJose(sampleVp, privateKey); const header = decodeHeader(token); - expect(header.typ).toBe("vp+ld+jwt"); + expect(header.typ).toBe("vp+jwt"); }); it("includes nonce and audience in payload", async () => { diff --git a/tests/typescript/harbour/tamper.test.ts b/tests/typescript/harbour/tamper.test.ts index a987af6..ee0e94e 100644 --- a/tests/typescript/harbour/tamper.test.ts +++ b/tests/typescript/harbour/tamper.test.ts @@ -32,7 +32,7 @@ describe("tamper detection", () => { Buffer.from(parts[1], "base64url").toString(), ) as Record; (payload.credentialSubject as any).id = - "did:web:did.ascs.digital:participants:evil"; + "did:web:evil.example.com"; const tampered = Buffer.from(JSON.stringify(payload)).toString("base64url"); const tamperedToken = `${parts[0]}.${tampered}.${parts[2]}`; From 69fc1b6a6835b645cc12ecb8f5bb89eb474b02c1 Mon Sep 17 00:00:00 2001 From: jdsika Date: Thu, 26 Feb 2026 11:53:56 +0100 Subject: [PATCH 05/78] fix(ci): add TypeScript build step to interop job The Cross-Runtime Interop CI job installed TypeScript dependencies but never ran `yarn build`, so the compiled `dist/` output (delegation.js, sd-jwt-vp.js) didn't exist. The 7 interop tests that import from `./dist/delegation.js` failed with "Qualified path resolution failed". CI fix: - Add `yarn build` step to `test-interop` job after `yarn install` Makefile fix: - `make test-all` now runs `build-ts` before `test`, ensuring interop tests have fresh compiled output even from a clean state Root cause: locally `dist/` persists across runs so the failure was never observed. CI starts fresh on every run. Signed-off-by: jdsika --- .github/workflows/ci.yml | 4 ++++ Makefile | 1 + 2 files changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fd73ddf..edf4cb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,5 +141,9 @@ jobs: working-directory: src/typescript/harbour run: yarn install --immutable + - name: Build TypeScript + working-directory: src/typescript/harbour + run: yarn build + - name: Run interop tests run: PYTHONPATH=src/python:$PYTHONPATH pytest tests/interop/test_cross_runtime.py -v --tb=short diff --git a/Makefile b/Makefile index e2e52e3..a98d5a7 100644 --- a/Makefile +++ b/Makefile @@ -274,6 +274,7 @@ all: # Run all tests (Python + TypeScript) test-all: @echo "🔧 Running all tests (Python + SHACL + TypeScript)..." + @$(MAKE) --no-print-directory build-ts @$(MAKE) --no-print-directory test @$(MAKE) --no-print-directory validate-shacl @$(MAKE) --no-print-directory test-ts From 072ea23ad96e3e256554bf495c440c31a2277a6d Mon Sep 17 00:00:00 2001 From: jdsika Date: Thu, 26 Feb 2026 14:01:19 +0100 Subject: [PATCH 06/78] refactor(examples): separate harbour skeletons from Gaia-X domain extensions Base examples are now harbour-only skeletons (no gxParticipant, no Gaia-X context). Gaia-X domain extensions live in examples/gaiax/ with schema:name as the authoritative name on the gxParticipant inner node (no duplicate name on the outer credentialSubject). LinkML schemas restructured: harbour.yaml contains only the abstract HarbourCredential base class plus evidence types, CRSet, and DID document structure. Concrete credential types (LegalPersonCredential, NaturalPersonCredential) and participant types live in gaiax-domain.yaml. ServiceOffering removed entirely. Claim mappings split into MAPPINGS (base) + GAIAX_MAPPINGS registries with context-aware get_mapping_for_vc() dispatch. Example signer auto-discovers gaiax/ subdirectory and uses per-file output directories. Signed-off-by: jdsika --- .gitignore | 1 + Makefile | 2 +- README.md | 10 +- examples/README.md | 33 +++- examples/gaiax/README.md | 48 ++++++ examples/gaiax/legal-person-credential.json | 78 +++++++++ examples/gaiax/natural-person-credential.json | 66 ++++++++ examples/legal-person-credential.json | 30 +--- examples/natural-person-credential.json | 14 +- examples/trust-anchor-credential.json | 23 +-- linkml/gaiax-domain.yaml | 137 +++++++--------- linkml/harbour.yaml | 2 +- src/python/credentials/claim_mapping.py | 88 +++++++--- src/python/credentials/example_signer.py | 35 ++-- .../python/credentials/test_claim_mapping.py | 153 +++++++++++++++--- .../python/credentials/test_example_signer.py | 68 ++++++++ tests/python/credentials/test_validation.py | 22 +-- 17 files changed, 591 insertions(+), 219 deletions(-) create mode 100644 examples/gaiax/README.md create mode 100644 examples/gaiax/legal-person-credential.json create mode 100644 examples/gaiax/natural-person-credential.json diff --git a/.gitignore b/.gitignore index b980931..2a243a7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ htmlcov/ artifacts/*/ examples/signed/ +examples/gaiax/signed/ dist/ .venv/ diff --git a/Makefile b/Makefile index a98d5a7..9a85985 100644 --- a/Makefile +++ b/Makefile @@ -196,7 +196,7 @@ validate-shacl: tmp_output=$$(mktemp) && \ $(PYTHON) -m src.tools.validators.validation_suite \ --run check-data-conformance \ - --data-paths ../../examples/ ../../tests/validation-probe/ontology-loading-probe.json \ + --data-paths ../../examples/ ../../examples/gaiax/ ../../tests/validation-probe/ontology-loading-probe.json \ --artifacts ../../artifacts > $$tmp_output 2>&1 ; \ status=$$? ; \ cat $$tmp_output ; \ diff --git a/README.md b/README.md index ecb3ef2..7026c3f 100644 --- a/README.md +++ b/README.md @@ -100,13 +100,13 @@ const payload = await verifyVcJose(jwt, publicKey); ## Harbour Credential Types -Harbour provides a base credential framework (`harbour.yaml`) and a Gaia-X domain layer (`gaiax-domain.yaml`) that adds participant and service offering types using a **composition pattern**: +Harbour provides a base credential framework (`harbour.yaml`) with **skeleton credentials** that define the minimum required structure. A Gaia-X domain layer (`gaiax-domain.yaml`) extends the skeletons with participant types using a **composition pattern**: | Credential Type | Subject Type | Composition Slot | Gaia-X Inner Type | | ----------------------------------- | ------------------------- | --------------------- | --------------------- | | `harbour:LegalPersonCredential` | `harbour:LegalPerson` | `gxParticipant` | `gx:LegalPerson` | | `harbour:NaturalPersonCredential` | `harbour:NaturalPerson` | `gxParticipant` | `gx:Participant` | -| `harbour:ServiceOfferingCredential` | `harbour:ServiceOffering` | `gxServiceOffering` | `gx:ServiceOffering` | + All harbour credentials require: @@ -114,7 +114,7 @@ All harbour credentials require: - `validFrom` - Mandatory datetime - `credentialStatus` - At least one `harbour:CRSetEntry` for revocation support -The composition pattern keeps harbour properties on the harbour-typed outer node and Gaia-X properties on a gx-typed inner blank node, so both harbour and Gaia-X SHACL shapes validate independently: +Base skeleton examples live in `examples/` (no Gaia-X data). Gaia-X domain extensions with `gxParticipant` live in `examples/gaiax/`. The composition pattern keeps harbour properties on the harbour-typed outer node and Gaia-X properties on a gx-typed inner blank node, so both harbour and Gaia-X SHACL shapes validate independently: ```json { @@ -233,9 +233,9 @@ submodules/ └── w3id.org/ # W3ID context resolution examples/ -├── legal-person-credential.json # Harbour credential examples +├── legal-person-credential.json # Harbour skeleton credentials ├── natural-person-credential.json # (canonical unsigned JSON-LD) -├── service-offering-credential.json +├── gaiax/ # Gaia-X domain extensions └── did-webs/ # Example did:webs DID documents used by examples tests/ diff --git a/examples/README.md b/examples/README.md index 5647454..ed40b5e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,6 +26,23 @@ flowchart LR end ``` +## Skeleton Credentials vs Domain Extensions + +The examples in this directory are **harbour skeletons** — they define the +minimum base structure required for each credential type, without any +domain-specific compliance data. The `delegated-signing-receipt.json` is the +canonical reference for the skeleton pattern. + +Domain extensions live in subdirectories and add compliance data on top of the +skeleton. Currently: + +- **`gaiax/`** — [Gaia-X domain extensions](gaiax/README.md): adds + `gxParticipant` inner nodes with Gaia-X properties (registration number, + addresses) and the `https://w3id.org/gaia-x/development#` context. + +The `gxParticipant` composition slot is defined as `required: false` in the +harbour schema — it is only populated when a domain extension needs it. + ## Credential Issuance Model The Harbour Signing Service is the **sole issuer** of all credentials, acting @@ -352,15 +369,22 @@ import { verifySdJwtVp, signJwt } from '@reachhaven/harbour-credentials'; ## File Index -### Credential examples (unsigned, expanded JSON-LD) +### Harbour skeletons (unsigned, expanded JSON-LD) | File | Step | Description | |------|------|-------------| | [`trust-anchor-credential.json`](trust-anchor-credential.json) | — | Trust Anchor self-signed credential (root of trust) | -| [`legal-person-credential.json`](legal-person-credential.json) | 1 | Organization credential with Gaia-X compliance data | -| [`natural-person-credential.json`](natural-person-credential.json) | 2 | Employee credential with `memberOf` link | +| [`legal-person-credential.json`](legal-person-credential.json) | 1 | Organization credential (harbour skeleton) | +| [`natural-person-credential.json`](natural-person-credential.json) | 2 | Employee credential with `memberOf` link (harbour skeleton) | | [`delegated-signing-receipt.json`](delegated-signing-receipt.json) | 3+4 | Transaction receipt with embedded consent VP as evidence | +### Gaia-X domain extensions (`gaiax/`) + +| File | Derives from | What's added | +|------|-------------|--------------| +| [`gaiax/legal-person-credential.json`](gaiax/legal-person-credential.json) | `legal-person-credential.json` | `gxParticipant` with registration number, addresses | +| [`gaiax/natural-person-credential.json`](gaiax/natural-person-credential.json) | `natural-person-credential.json` | `gxParticipant` with `gx:Participant` | + ### Signed artifacts (`signed/`) @@ -389,7 +413,8 @@ source .venv/bin/activate PYTHONPATH=src/python:$PYTHONPATH python -m credentials.example_signer examples/ ``` -This signs all `examples/*.json` files and writes artifacts to `examples/signed/`. +This signs all `examples/*.json` and `examples/gaiax/*.json` files, writing +artifacts to `examples/signed/` and `examples/gaiax/signed/` respectively. > **Wire format vs JSON-LD**: The `.json` files in this directory show credentials > as expanded JSON-LD for readability. On the wire, every credential and VP is diff --git a/examples/gaiax/README.md b/examples/gaiax/README.md new file mode 100644 index 0000000..21f25fd --- /dev/null +++ b/examples/gaiax/README.md @@ -0,0 +1,48 @@ +# Gaia-X Domain Extensions + +This directory contains **Gaia-X domain extensions** of the harbour credential +skeletons in the parent `examples/` directory. Each file adds Gaia-X compliance +data to the base harbour skeleton using the **composition pattern**. + +## Composition Pattern + +Harbour credentials use a two-layer model: + +1. **Outer node** (`harbour:LegalPerson` / `harbour:NaturalPerson`) — harbour-owned + properties (`name`, `memberOf`, CRSet status). +2. **Inner node** (`gxParticipant`) — a Gaia-X typed blank node + (`gx:LegalPerson`, `gx:Participant`) carrying Gaia-X properties + (`gx:registrationNumber`, `gx:headquartersAddress`, etc.). + +This keeps harbour and Gaia-X SHACL shapes validating independently. The +`gxParticipant` slot is defined as `required: false` in the harbour schema — it +is only populated when Gaia-X compliance is needed. + +## Skeleton to Extension Derivation + +| Skeleton (parent `examples/`) | Gaia-X extension (this directory) | What's added | +|-------------------------------|-----------------------------------|--------------| +| `legal-person-credential.json` | `legal-person-credential.json` | `gxParticipant` with `gx:LegalPerson`, registration number, addresses | +| `natural-person-credential.json` | `natural-person-credential.json` | `gxParticipant` with `gx:Participant` | + +The `@context` array in Gaia-X extensions includes the Gaia-X namespace: + +```json +"@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#", + "https://w3id.org/reachhaven/harbour/credentials/v1/" +] +``` + +Base skeletons omit this context entry entirely. + +## Regenerating Signed Artifacts + +```bash +source .venv/bin/activate +PYTHONPATH=src/python:$PYTHONPATH python -m credentials.example_signer examples/ +``` + +This processes both `examples/*.json` and `examples/gaiax/*.json`, producing +signed artifacts in `examples/signed/` and `examples/gaiax/signed/` respectively. diff --git a/examples/gaiax/legal-person-credential.json b/examples/gaiax/legal-person-credential.json new file mode 100644 index 0000000..7a0b64c --- /dev/null +++ b/examples/gaiax/legal-person-credential.json @@ -0,0 +1,78 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#", + "https://w3id.org/reachhaven/harbour/credentials/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:LegalPersonCredential" + ], + "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "validFrom": "2024-01-15T00:00:00Z", + "validUntil": "2025-01-15T00:00:00Z", + "credentialSubject": { + "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "type": "harbour:LegalPerson", + "gxParticipant": { + "type": "gx:LegalPerson", + "schema:name": "Example Corporation GmbH", + "gx:registrationNumber": { + "type": "gx:RegistrationNumber", + "schema:taxID": "DE123456789" + }, + "gx:headquartersAddress": { + "type": "gx:Address", + "gx:countryCode": "DE", + "gx:countryName": "Germany", + "vcard:locality": "Munich" + }, + "gx:legalAddress": { + "type": "gx:Address", + "gx:countryCode": "DE", + "gx:countryName": "Germany", + "vcard:locality": "Munich" + } + } + }, + "credentialStatus": [ + { + "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry#a1b2c3d4e5f67890", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ], + "evidence": [ + { + "type": "harbour:CredentialEvidence", + "verifiablePresentation": { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["VerifiablePresentation"], + "holder": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#", + "https://w3id.org/reachhaven/harbour/credentials/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:LegalPersonCredential" + ], + "issuer": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "credentialSubject": { + "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "type": "harbour:LegalPerson", + "gxParticipant": { + "type": "gx:LegalPerson", + "schema:name": "ReachHaven GmbH" + } + } + } + ] + } + } + ] +} diff --git a/examples/gaiax/natural-person-credential.json b/examples/gaiax/natural-person-credential.json new file mode 100644 index 0000000..5db6904 --- /dev/null +++ b/examples/gaiax/natural-person-credential.json @@ -0,0 +1,66 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#", + "https://w3id.org/reachhaven/harbour/credentials/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:NaturalPersonCredential" + ], + "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f23456789012", + "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "validFrom": "2024-01-15T00:00:00Z", + "validUntil": "2025-01-15T00:00:00Z", + "credentialSubject": { + "id": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", + "type": "harbour:NaturalPerson", + "schema:givenName": "Alice", + "schema:familyName": "Smith", + "schema:email": "alice.smith@example.com", + "memberOf": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "gxParticipant": { + "type": "gx:Participant", + "schema:name": "Alice Smith" + } + }, + "credentialStatus": [ + { + "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry#b2c3d4e5f6a78901", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ], + "evidence": [ + { + "type": "harbour:CredentialEvidence", + "verifiablePresentation": { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["VerifiablePresentation"], + "holder": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#", + "https://w3id.org/reachhaven/harbour/credentials/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:LegalPersonCredential" + ], + "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "credentialSubject": { + "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "type": "harbour:LegalPerson", + "gxParticipant": { + "type": "gx:LegalPerson", + "schema:name": "Example Corporation GmbH" + } + } + } + ] + } + } + ] +} diff --git a/examples/legal-person-credential.json b/examples/legal-person-credential.json index 08d526b..e4b9a03 100644 --- a/examples/legal-person-credential.json +++ b/examples/legal-person-credential.json @@ -1,7 +1,6 @@ { "@context": [ "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/gaia-x/development#", "https://w3id.org/reachhaven/harbour/credentials/v1/" ], "type": [ @@ -15,27 +14,7 @@ "credentialSubject": { "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", "type": "harbour:LegalPerson", - "name": "Example Corporation GmbH", - "gxParticipant": { - "type": "gx:LegalPerson", - "schema:name": "Example Corporation GmbH", - "gx:registrationNumber": { - "type": "gx:RegistrationNumber", - "schema:taxID": "DE123456789" - }, - "gx:headquartersAddress": { - "type": "gx:Address", - "gx:countryCode": "DE", - "gx:countryName": "Germany", - "vcard:locality": "Munich" - }, - "gx:legalAddress": { - "type": "gx:Address", - "gx:countryCode": "DE", - "gx:countryName": "Germany", - "vcard:locality": "Munich" - } - } + "name": "Example Corporation GmbH" }, "credentialStatus": [ { @@ -55,7 +34,6 @@ { "@context": [ "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/gaia-x/development#", "https://w3id.org/reachhaven/harbour/credentials/v1/" ], "type": [ @@ -66,11 +44,7 @@ "credentialSubject": { "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", "type": "harbour:LegalPerson", - "name": "ReachHaven GmbH", - "gxParticipant": { - "type": "gx:LegalPerson", - "schema:name": "ReachHaven GmbH" - } + "name": "ReachHaven GmbH" } } ] diff --git a/examples/natural-person-credential.json b/examples/natural-person-credential.json index 0cf3abe..ac66c59 100644 --- a/examples/natural-person-credential.json +++ b/examples/natural-person-credential.json @@ -1,7 +1,6 @@ { "@context": [ "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/gaia-x/development#", "https://w3id.org/reachhaven/harbour/credentials/v1/" ], "type": [ @@ -19,11 +18,7 @@ "schema:givenName": "Alice", "schema:familyName": "Smith", "schema:email": "alice.smith@example.com", - "memberOf": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", - "gxParticipant": { - "type": "gx:Participant", - "schema:name": "Alice Smith" - } + "memberOf": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" }, "credentialStatus": [ { @@ -43,7 +38,6 @@ { "@context": [ "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/gaia-x/development#", "https://w3id.org/reachhaven/harbour/credentials/v1/" ], "type": [ @@ -54,11 +48,7 @@ "credentialSubject": { "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", "type": "harbour:LegalPerson", - "name": "Example Corporation GmbH", - "gxParticipant": { - "type": "gx:LegalPerson", - "schema:name": "Example Corporation GmbH" - } + "name": "Example Corporation GmbH" } } ] diff --git a/examples/trust-anchor-credential.json b/examples/trust-anchor-credential.json index 46068c4..d90984d 100644 --- a/examples/trust-anchor-credential.json +++ b/examples/trust-anchor-credential.json @@ -1,7 +1,6 @@ { "@context": [ "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/gaia-x/development#", "https://w3id.org/reachhaven/harbour/credentials/v1/" ], "type": [ @@ -14,27 +13,7 @@ "credentialSubject": { "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", "type": "harbour:LegalPerson", - "name": "ReachHaven GmbH", - "gxParticipant": { - "type": "gx:LegalPerson", - "schema:name": "ReachHaven GmbH", - "gx:registrationNumber": { - "type": "gx:RegistrationNumber", - "schema:taxID": "DE987654321" - }, - "gx:headquartersAddress": { - "type": "gx:Address", - "gx:countryCode": "DE", - "gx:countryName": "Germany", - "vcard:locality": "Berlin" - }, - "gx:legalAddress": { - "type": "gx:Address", - "gx:countryCode": "DE", - "gx:countryName": "Germany", - "vcard:locality": "Berlin" - } - } + "name": "ReachHaven GmbH" }, "credentialStatus": [ { diff --git a/linkml/gaiax-domain.yaml b/linkml/gaiax-domain.yaml index d81d20b..ddaca2c 100644 --- a/linkml/gaiax-domain.yaml +++ b/linkml/gaiax-domain.yaml @@ -2,10 +2,10 @@ id: https://w3id.org/reachhaven/harbour/gaiax-domain/v1 name: gaiax-domain description: > Gaia-X domain layer for Harbour credentials. - Defines participant and service offering types that wrap Gaia-X - compliance data via composition. Harbour outer nodes carry - harbour-specific properties; nested gx blank nodes carry only gx - properties, keeping gx closed SHACL shapes intact. + Defines participant types that wrap Gaia-X compliance data via + composition. Harbour outer nodes carry harbour-specific properties; + nested gx blank nodes carry only gx properties, keeping gx closed + SHACL shapes intact. prefixes: linkml: https://w3id.org/linkml/ @@ -31,7 +31,7 @@ imports: # Solution: composition — harbour outer node owns harbour properties; # nested gx blank node carries only gx properties. # Spec: Gaia-X Trust Framework 22.10 — gx:LegalPerson, gx:Participant, -# gx:ServiceOffering are closed SHACL shapes requiring specific properties. +# Gaia-X shapes requiring specific properties. slots: gxParticipant: @@ -42,51 +42,84 @@ slots: range: Any required: false - gxServiceOffering: - description: > - Nested Gaia-X service offering compliance data - (gx:ServiceOffering blank node). - slot_uri: harbour:gxServiceOffering - range: Any - required: false classes: # ========================================== - # 1. PARTICIPANT TYPES + # 1. CREDENTIAL TYPES + # ========================================== + # Concrete credential types for harbour use case stories. + # Defined in the domain layer because the credentialSubject types + # (LegalPerson, NaturalPerson) carry domain-specific composition + # slots (gxParticipant) that must be present in the SHACL closed shapes. + # Spec: VCDM2 §4 — each credential MUST have @context, type, issuer, + # credentialSubject. Harbour profile additionally requires validFrom + # and credentialStatus (inherited from HarbourCredential). + + LegalPersonCredential: + is_a: HarbourCredential + description: > + Credential attesting to a harbour:LegalPerson (organization) identity. + In skeleton form the credentialSubject carries only harbour properties + (name). When Gaia-X compliance is needed, gxParticipant carries the + gx:LegalPerson blank node with compliance data. + class_uri: harbour:LegalPersonCredential + slot_usage: + validFrom: + required: true + evidence: + required: true + + NaturalPersonCredential: + is_a: HarbourCredential + description: > + Credential attesting to a harbour:NaturalPerson (individual) identity. + In skeleton form the credentialSubject carries only harbour properties + (name, givenName, familyName, email, memberOf). When Gaia-X compliance + is needed, gxParticipant carries the gx:Participant blank node. + class_uri: harbour:NaturalPersonCredential + slot_usage: + validFrom: + required: true + evidence: + required: true + + # ========================================== + # 2. PARTICIPANT TYPES # ========================================== # Harbour wraps Gaia-X participant types via composition. # Gaia-X data lives in nested blank nodes (gxParticipant / - # gxServiceOffering) to keep gx closed shapes intact. + # gxParticipant) to keep gx closed shapes intact. # Spec: Gaia-X TF — gx:LegalPerson requires gx:registrationNumber (object), # gx:headquartersAddress, gx:legalAddress. gx:Participant is the base type. LegalPerson: description: > A legal person (organization) participating in the harbour ecosystem. - Gaia-X compliance data is nested in the gxParticipant slot as a - gx:LegalPerson blank node, keeping the gx closed shape intact. + In skeleton form only the harbour name property is present. When Gaia-X + compliance is needed, name is omitted from the outer node and schema:name + lives in the gxParticipant inner node (gx:LegalPerson blank node), + keeping the gx closed shape intact. class_uri: harbour:LegalPerson slots: - # Spec: schema.org — schema:name for human-readable organization name. - # NOTE: gx:legalName is NOT a valid gx:LegalPerson property in current - # Gaia-X shapes. Use schema:name on the harbour outer node instead. - name - gxParticipant slot_usage: name: - required: true + required: false NaturalPerson: description: > A natural person (individual) participating in the harbour ecosystem. - Gaia-X participant data is nested in the gxParticipant slot. + In skeleton form only harbour properties are present. When Gaia-X + compliance is needed, name is omitted and schema:name lives in the + gxParticipant inner node (gx:Participant blank node). class_uri: harbour:NaturalPerson slots: - name - gxParticipant slot_usage: name: - required: true + required: false attributes: # Spec: schema.org Person vocabulary — givenName, familyName, email. givenName: @@ -104,63 +137,3 @@ classes: slot_uri: schema:memberOf range: uri - ServiceOffering: - description: > - A service offering available in the harbour ecosystem. - Gaia-X compliance data is nested in the gxServiceOffering slot. - class_uri: harbour:ServiceOffering - slots: - - name - - description - - gxServiceOffering - slot_usage: - name: - required: true - - # ========================================== - # 2. CREDENTIAL TYPES - # ========================================== - # Spec: VCDM2 §4 — each credential MUST have @context, type, issuer, - # credentialSubject. Harbour profile additionally requires validFrom - # and credentialStatus (inherited from HarbourCredential). - # Spec: SD-JWT-VC draft-14 — uses vct claim instead of type array. - # Harbour claim_mapping.py bridges W3C ↔ SD-JWT-VC formats. - - LegalPersonCredential: - is_a: HarbourCredential - description: > - Credential attesting to a harbour:LegalPerson (organization) identity. - The credentialSubject wraps a gx:LegalPerson via the gxParticipant slot. - class_uri: harbour:LegalPersonCredential - slot_usage: - validFrom: - required: true - # Evidence REQUIRED: Trust Anchor authorizes issuance via VP containing - # its self-signed LegalPersonCredential (root of trust). - evidence: - required: true - - NaturalPersonCredential: - is_a: HarbourCredential - description: > - Credential attesting to a harbour:NaturalPerson (individual) identity. - The credentialSubject wraps gx:Participant via the gxParticipant slot. - class_uri: harbour:NaturalPersonCredential - slot_usage: - validFrom: - required: true - # Evidence REQUIRED: organization authorizes issuance via VP containing - # its LegalPersonCredential (SD-JWT, sensitive fields redacted). - evidence: - required: true - - ServiceOfferingCredential: - is_a: HarbourCredential - description: > - Credential attesting to a harbour:ServiceOffering. - The credentialSubject wraps a gx:ServiceOffering via the - gxServiceOffering slot. - class_uri: harbour:ServiceOfferingCredential - slot_usage: - validFrom: - required: true diff --git a/linkml/harbour.yaml b/linkml/harbour.yaml index b867442..ffa0e0b 100644 --- a/linkml/harbour.yaml +++ b/linkml/harbour.yaml @@ -390,7 +390,7 @@ classes: - statusPurpose # ========================================== - # 5. HELPERS + # 6. HELPERS # ========================================== # Spec: DID-Core §5.3.1 — verificationMethod MUST have id, type, controller, # and key material (publicKeyJwk or publicKeyMultibase). Harbour models a diff --git a/src/python/credentials/claim_mapping.py b/src/python/credentials/claim_mapping.py index 14f70e6..1752ddc 100644 --- a/src/python/credentials/claim_mapping.py +++ b/src/python/credentials/claim_mapping.py @@ -28,16 +28,44 @@ # Gaia-X namespace GAIAX_NS = "https://w3id.org/gaia-x/development#" +# --------------------------------------------------------------------------- +# Base harbour mappings (skeleton credentials — no Gaia-X) +# --------------------------------------------------------------------------- + HARBOUR_LEGAL_PERSON_MAPPING = { "vct": f"{HARBOUR_NS}LegalPersonCredential", "claims": { "credentialSubject.name": "name", + }, + "always_disclosed": ["iss", "vct", "iat", "exp", "name"], + "selectively_disclosed": [], +} + +HARBOUR_NATURAL_PERSON_MAPPING = { + "vct": f"{HARBOUR_NS}NaturalPersonCredential", + "claims": { + "credentialSubject.schema:givenName": "givenName", + "credentialSubject.schema:familyName": "familyName", + "credentialSubject.schema:email": "email", + "credentialSubject.memberOf": "memberOf", + }, + "always_disclosed": ["iss", "vct", "iat", "exp"], + "selectively_disclosed": ["givenName", "familyName", "email", "memberOf"], +} + +# --------------------------------------------------------------------------- +# Gaia-X domain mappings (extends base with gxParticipant) +# --------------------------------------------------------------------------- + +GAIAX_LEGAL_PERSON_MAPPING = { + "vct": f"{HARBOUR_NS}LegalPersonCredential", + "claims": { "credentialSubject.gxParticipant.schema:name": "legalName", "credentialSubject.gxParticipant.gx:registrationNumber": "registrationNumber", "credentialSubject.gxParticipant.gx:headquartersAddress": "headquartersAddress", "credentialSubject.gxParticipant.gx:legalAddress": "legalAddress", }, - "always_disclosed": ["iss", "vct", "iat", "exp", "name", "legalName"], + "always_disclosed": ["iss", "vct", "iat", "exp", "legalName"], "selectively_disclosed": [ "registrationNumber", "headquartersAddress", @@ -45,36 +73,39 @@ ], } -HARBOUR_NATURAL_PERSON_MAPPING = { +GAIAX_NATURAL_PERSON_MAPPING = { "vct": f"{HARBOUR_NS}NaturalPersonCredential", "claims": { + "credentialSubject.gxParticipant.schema:name": "gxName", "credentialSubject.schema:givenName": "givenName", "credentialSubject.schema:familyName": "familyName", "credentialSubject.schema:email": "email", "credentialSubject.memberOf": "memberOf", }, "always_disclosed": ["iss", "vct", "iat", "exp"], - "selectively_disclosed": ["givenName", "familyName", "email", "memberOf"], + "selectively_disclosed": [ + "gxName", + "givenName", + "familyName", + "email", + "memberOf", + ], } -HARBOUR_SERVICE_OFFERING_MAPPING = { - "vct": f"{HARBOUR_NS}ServiceOfferingCredential", - "claims": { - "credentialSubject.name": "name", - "credentialSubject.description": "description", - "credentialSubject.gxServiceOffering.gx:providedBy": "providedBy", - "credentialSubject.gxServiceOffering.gx:serviceOfferingTermsAndConditions": "termsAndConditions", - }, - "always_disclosed": ["iss", "vct", "iat", "exp", "providedBy", "name"], - "selectively_disclosed": ["description", "termsAndConditions"], -} +# --------------------------------------------------------------------------- +# Registries +# --------------------------------------------------------------------------- -# Registry: VC type string → mapping dict -# Additional mappings can be registered at runtime +# Base harbour mappings (skeleton credentials) MAPPINGS: dict[str, dict] = { "harbour:LegalPersonCredential": HARBOUR_LEGAL_PERSON_MAPPING, "harbour:NaturalPersonCredential": HARBOUR_NATURAL_PERSON_MAPPING, - "harbour:ServiceOfferingCredential": HARBOUR_SERVICE_OFFERING_MAPPING, +} + +# Gaia-X domain mappings (extended with gxParticipant) +GAIAX_MAPPINGS: dict[str, dict] = { + "harbour:LegalPersonCredential": GAIAX_LEGAL_PERSON_MAPPING, + "harbour:NaturalPersonCredential": GAIAX_NATURAL_PERSON_MAPPING, } @@ -179,8 +210,19 @@ def sd_jwt_claims_to_vc(claims: dict, mapping: dict, vc_type: str) -> dict: return vc +def _has_gaiax_context(vc: dict) -> bool: + """Check whether a VC's @context includes the Gaia-X namespace.""" + ctx = vc.get("@context", []) + if isinstance(ctx, str): + ctx = [ctx] + return GAIAX_NS in ctx + + def get_mapping_for_vc(vc: dict) -> dict | None: - """Find the matching mapping for a VC based on its type. + """Find the matching mapping for a VC based on its type and context. + + Context-aware: if the VC's @context includes the Gaia-X namespace, + returns the Gaia-X mapping; otherwise returns the base harbour mapping. Supports both: - W3C VCDM: "type" array (e.g., ["VerifiableCredential", "PersonCredential"]) @@ -205,7 +247,10 @@ def get_mapping_for_vc(vc: dict) -> dict | None: elif isinstance(at_type, list): vc_types = vc_types + at_type - for vc_type, mapping in MAPPINGS.items(): + # Choose registry based on context + registry = GAIAX_MAPPINGS if _has_gaiax_context(vc) else MAPPINGS + + for vc_type, mapping in registry.items(): if vc_type in vc_types: return mapping return None @@ -362,9 +407,14 @@ def main() -> None: elif args.command == "list-types": print("Supported credential types:") + print("\nBase harbour mappings:") for type_key, mapping in MAPPINGS.items(): print(f" {type_key}") print(f" vct: {mapping['vct']}") + print("\nGaia-X domain mappings:") + for type_key, mapping in GAIAX_MAPPINGS.items(): + print(f" {type_key}") + print(f" vct: {mapping['vct']}") if __name__ == "__main__": diff --git a/src/python/credentials/example_signer.py b/src/python/credentials/example_signer.py index 15f42fb..19b5b92 100644 --- a/src/python/credentials/example_signer.py +++ b/src/python/credentials/example_signer.py @@ -2,6 +2,8 @@ Reads expanded (human-readable) examples from examples/*.json and produces wire-format signed JWTs plus decoded companion files in examples/signed/. +When given a directory, also processes gaiax/ subdirectory if present, +outputting signed artifacts to each subdirectory's own signed/ folder. Output per credential: - .jwt — VC-JOSE-COSE compact JWS (wire format) @@ -251,6 +253,15 @@ def main(): if p.parent.name != "signed" and any(t in p.stem for t in ("credential", "receipt", "offering")) ) + # Also process gaiax/ subdirectory if it exists + gaiax_dir = path / "gaiax" + if gaiax_dir.is_dir(): + example_files.extend( + p + for p in sorted(gaiax_dir.glob("*.json")) + if p.parent.name != "signed" + and any(t in p.stem for t in ("credential", "receipt", "offering")) + ) elif path.is_file(): example_files.append(path) else: @@ -260,24 +271,26 @@ def main(): print("No example credentials found", file=sys.stderr) sys.exit(1) - # Determine output directory - if args.output_dir: - output_dir = Path(args.output_dir) - else: - # Use first input's parent/signed/ - output_dir = example_files[0].parent / "signed" - print(f"Signing {len(example_files)} example credentials...") print(f" kid: {kid_vm}") - print(f" output: {output_dir}") + output_dirs_used: set[Path] = set() for path in example_files: + # Per-file output: each file's signed artifacts go to file.parent / "signed" + if args.output_dir: + output_dir = Path(args.output_dir) + else: + output_dir = path.parent / "signed" jwt_path = process_example(path, private_key, kid_vm, output_dir) - print(f" {path.name} -> {jwt_path.name}") + output_dirs_used.add(output_dir) + rel = path.parent.name + prefix = f"{rel}/" if rel != "examples" else "" + print(f" {prefix}{path.name} -> {output_dir.name}/{jwt_path.name}") # List all generated files - signed_files = sorted(output_dir.iterdir()) - print(f"\nGenerated {len(signed_files)} files in {output_dir}/") + for out_dir in sorted(output_dirs_used): + signed_files = sorted(out_dir.iterdir()) + print(f"\nGenerated {len(signed_files)} files in {out_dir}/") print("Done.") diff --git a/tests/python/credentials/test_claim_mapping.py b/tests/python/credentials/test_claim_mapping.py index cb1f16a..90d0648 100644 --- a/tests/python/credentials/test_claim_mapping.py +++ b/tests/python/credentials/test_claim_mapping.py @@ -4,6 +4,8 @@ from pathlib import Path from credentials.claim_mapping import ( + GAIAX_MAPPINGS, + GAIAX_NS, MAPPINGS, create_mapping, get_mapping_for_vc, @@ -17,14 +19,21 @@ _REPO_ROOT = _REPO_ROOT.parent EXAMPLES_DIR = _REPO_ROOT / "examples" +GAIAX_EXAMPLES_DIR = EXAMPLES_DIR / "gaiax" -def _load_fixture(name: str) -> dict: +def _load_fixture(name: str, gaiax: bool = False) -> dict: """Load a credential example from the examples directory.""" - with open(EXAMPLES_DIR / name) as f: + base = GAIAX_EXAMPLES_DIR if gaiax else EXAMPLES_DIR + with open(base / name) as f: return json.load(f) +# --------------------------------------------------------------------------- +# Base harbour skeleton tests +# --------------------------------------------------------------------------- + + class TestHarbourLegalPersonMapping: def test_vc_to_claims(self): vc = _load_fixture("legal-person-credential.json") @@ -40,9 +49,10 @@ def test_vc_to_claims(self): == "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" ) assert claims["name"] == "Example Corporation GmbH" - assert claims["legalName"] == "Example Corporation GmbH" - assert "registrationNumber" in claims - assert "registrationNumber" in disclosable + # Base mapping has no gx claims + assert "legalName" not in claims + assert "registrationNumber" not in claims + assert disclosable == [] def test_has_credential_status(self): vc = _load_fixture("legal-person-credential.json") @@ -57,17 +67,15 @@ def test_subject_is_harbour_legal_person(self): subject_type = vc["credentialSubject"]["type"] assert subject_type == "harbour:LegalPerson" - def test_gx_inner_node_exists(self): - """Verify gx:LegalPerson data lives in the gxParticipant inner node.""" + def test_no_gx_participant(self): + """Base skeleton must not contain gxParticipant.""" vc = _load_fixture("legal-person-credential.json") - subject = vc["credentialSubject"] - gx = subject["gxParticipant"] - assert gx["type"] == "gx:LegalPerson" - assert "schema:name" in gx - assert "gx:registrationNumber" in gx - # gx properties must NOT be on the outer node - # schema:name is on both nodes (outer via harbour, inner via gx) - assert "gx:registrationNumber" not in subject + assert "gxParticipant" not in vc["credentialSubject"] + + def test_no_gaiax_context(self): + """Base skeleton must not reference the Gaia-X namespace.""" + vc = _load_fixture("legal-person-credential.json") + assert GAIAX_NS not in vc["@context"] def test_roundtrip(self): vc = _load_fixture("legal-person-credential.json") @@ -77,8 +85,8 @@ def test_roundtrip(self): claims, mapping, "harbour:LegalPersonCredential" ) assert ( - reconstructed["credentialSubject"]["gxParticipant"]["schema:name"] - == vc["credentialSubject"]["gxParticipant"]["schema:name"] + reconstructed["credentialSubject"]["name"] + == vc["credentialSubject"]["name"] ) @@ -112,12 +120,15 @@ def test_subject_is_harbour_natural_person(self): subject_type = vc["credentialSubject"]["type"] assert subject_type == "harbour:NaturalPerson" - def test_gx_inner_node_exists(self): - """Verify gx:Participant data lives in the gxParticipant inner node.""" + def test_no_gx_participant(self): + """Base skeleton must not contain gxParticipant.""" vc = _load_fixture("natural-person-credential.json") - subject = vc["credentialSubject"] - gx = subject["gxParticipant"] - assert gx["type"] == "gx:Participant" + assert "gxParticipant" not in vc["credentialSubject"] + + def test_no_gaiax_context(self): + """Base skeleton must not reference the Gaia-X namespace.""" + vc = _load_fixture("natural-person-credential.json") + assert GAIAX_NS not in vc["@context"] def test_roundtrip(self): vc = _load_fixture("natural-person-credential.json") @@ -132,12 +143,108 @@ def test_roundtrip(self): ) +# --------------------------------------------------------------------------- +# Gaia-X domain extension tests +# --------------------------------------------------------------------------- + + +class TestGaiaxLegalPersonMapping: + def test_vc_to_claims(self): + vc = _load_fixture("legal-person-credential.json", gaiax=True) + mapping = GAIAX_MAPPINGS["harbour:LegalPersonCredential"] + claims, disclosable = vc_to_sd_jwt_claims(vc, mapping) + + # Gaia-X extension uses gxParticipant.schema:name, not outer name + assert "name" not in claims + assert claims["legalName"] == "Example Corporation GmbH" + assert "registrationNumber" in claims + assert "registrationNumber" in disclosable + + def test_gx_inner_node_exists(self): + """Verify gx:LegalPerson data lives in the gxParticipant inner node.""" + vc = _load_fixture("legal-person-credential.json", gaiax=True) + subject = vc["credentialSubject"] + gx = subject["gxParticipant"] + assert gx["type"] == "gx:LegalPerson" + assert "schema:name" in gx + assert "gx:registrationNumber" in gx + # gx properties must NOT be on the outer node + assert "gx:registrationNumber" not in subject + + def test_has_gaiax_context(self): + """Gaia-X extension must include the Gaia-X namespace in @context.""" + vc = _load_fixture("legal-person-credential.json", gaiax=True) + assert GAIAX_NS in vc["@context"] + + def test_no_outer_name(self): + """Gaia-X extension should NOT have name on the outer node.""" + vc = _load_fixture("legal-person-credential.json", gaiax=True) + assert "name" not in vc["credentialSubject"] + + def test_roundtrip(self): + vc = _load_fixture("legal-person-credential.json", gaiax=True) + mapping = GAIAX_MAPPINGS["harbour:LegalPersonCredential"] + claims, _ = vc_to_sd_jwt_claims(vc, mapping) + reconstructed = sd_jwt_claims_to_vc( + claims, mapping, "harbour:LegalPersonCredential" + ) + assert ( + reconstructed["credentialSubject"]["gxParticipant"]["schema:name"] + == vc["credentialSubject"]["gxParticipant"]["schema:name"] + ) + + +class TestGaiaxNaturalPersonMapping: + def test_vc_to_claims(self): + vc = _load_fixture("natural-person-credential.json", gaiax=True) + mapping = GAIAX_MAPPINGS["harbour:NaturalPersonCredential"] + claims, disclosable = vc_to_sd_jwt_claims(vc, mapping) + + assert claims["givenName"] == "Alice" + assert claims["gxName"] == "Alice Smith" + assert "gxName" in disclosable + + def test_gx_inner_node_exists(self): + """Verify gx:Participant data lives in the gxParticipant inner node.""" + vc = _load_fixture("natural-person-credential.json", gaiax=True) + subject = vc["credentialSubject"] + gx = subject["gxParticipant"] + assert gx["type"] == "gx:Participant" + + def test_no_outer_name(self): + """Gaia-X extension should NOT have name on the outer node.""" + vc = _load_fixture("natural-person-credential.json", gaiax=True) + assert "name" not in vc["credentialSubject"] + + def test_has_gaiax_context(self): + """Gaia-X extension must include the Gaia-X namespace in @context.""" + vc = _load_fixture("natural-person-credential.json", gaiax=True) + assert GAIAX_NS in vc["@context"] + + +# --------------------------------------------------------------------------- +# Context-aware mapping discovery +# --------------------------------------------------------------------------- + + class TestMappingDiscovery: - def test_get_mapping_for_harbour_credential(self): + def test_get_mapping_for_harbour_skeleton(self): + """Base skeleton (no Gaia-X context) should return base mapping.""" vc = _load_fixture("legal-person-credential.json") mapping = get_mapping_for_vc(vc) assert mapping is not None assert "LegalPersonCredential" in mapping["vct"] + # Base mapping should NOT have gxParticipant paths + assert "credentialSubject.gxParticipant.schema:name" not in mapping["claims"] + + def test_get_mapping_for_gaiax_extension(self): + """Gaia-X extension (with Gaia-X context) should return Gaia-X mapping.""" + vc = _load_fixture("legal-person-credential.json", gaiax=True) + mapping = get_mapping_for_vc(vc) + assert mapping is not None + assert "LegalPersonCredential" in mapping["vct"] + # Gaia-X mapping should have gxParticipant paths + assert "credentialSubject.gxParticipant.schema:name" in mapping["claims"] def test_get_mapping_for_unknown(self): vc = {"type": ["VerifiableCredential", "UnknownType"]} diff --git a/tests/python/credentials/test_example_signer.py b/tests/python/credentials/test_example_signer.py index b15ab73..c4a0df0 100644 --- a/tests/python/credentials/test_example_signer.py +++ b/tests/python/credentials/test_example_signer.py @@ -24,6 +24,7 @@ _REPO_ROOT = _REPO_ROOT.parent EXAMPLES_DIR = _REPO_ROOT / "examples" +GAIAX_EXAMPLES_DIR = EXAMPLES_DIR / "gaiax" @pytest.fixture(scope="module") @@ -210,3 +211,70 @@ def test_process_all_examples(self, signing_key, tmp_path): vc_jwt = jwt_path.read_text().strip() vc_payload = verify_vc_jose(vc_jwt, public_key) assert "VerifiableCredential" in vc_payload["type"] + + +class TestProcessGaiaxExample: + """Test processing Gaia-X domain extension examples.""" + + def test_process_gaiax_legal_person(self, signing_key, tmp_path): + """Process the Gaia-X legal person credential through the pipeline.""" + private_key, public_key, kid = signing_key + + example_path = GAIAX_EXAMPLES_DIR / "legal-person-credential.json" + if not example_path.exists(): + pytest.skip("examples/gaiax/ not populated") + + output_dir = tmp_path / "gaiax" / "signed" + jwt_path = process_example(example_path, private_key, kid, output_dir) + + assert jwt_path.exists() + assert (output_dir / "legal-person-credential.decoded.json").exists() + assert (output_dir / "legal-person-credential.evidence-vp.jwt").exists() + + # Verify outer VC JWT + vc_jwt = jwt_path.read_text().strip() + vc_payload = verify_vc_jose(vc_jwt, public_key) + assert "harbour:LegalPersonCredential" in vc_payload["type"] + + # Gaia-X credential should have gxParticipant in subject + subject = vc_payload["credentialSubject"] + assert "gxParticipant" in subject + assert subject["gxParticipant"]["type"] == "gx:LegalPerson" + + def test_process_gaiax_natural_person(self, signing_key, tmp_path): + """Process the Gaia-X natural person credential through the pipeline.""" + private_key, public_key, kid = signing_key + + example_path = GAIAX_EXAMPLES_DIR / "natural-person-credential.json" + if not example_path.exists(): + pytest.skip("examples/gaiax/ not populated") + + output_dir = tmp_path / "gaiax" / "signed" + jwt_path = process_example(example_path, private_key, kid, output_dir) + + assert jwt_path.exists() + vc_jwt = jwt_path.read_text().strip() + vc_payload = verify_vc_jose(vc_jwt, public_key) + assert "harbour:NaturalPersonCredential" in vc_payload["type"] + + # Gaia-X credential should have gxParticipant in subject + subject = vc_payload["credentialSubject"] + assert "gxParticipant" in subject + + def test_process_all_gaiax_examples(self, signing_key, tmp_path): + """Process all Gaia-X examples and verify each produces a valid JWT.""" + private_key, public_key, kid = signing_key + + if not GAIAX_EXAMPLES_DIR.is_dir(): + pytest.skip("examples/gaiax/ not populated") + + example_files = sorted(GAIAX_EXAMPLES_DIR.glob("*-credential.json")) + if not example_files: + pytest.skip("No Gaia-X credential examples found") + + output_dir = tmp_path / "gaiax" / "signed" + for path in example_files: + jwt_path = process_example(path, private_key, kid, output_dir) + vc_jwt = jwt_path.read_text().strip() + vc_payload = verify_vc_jose(vc_jwt, public_key) + assert "VerifiableCredential" in vc_payload["type"] diff --git a/tests/python/credentials/test_validation.py b/tests/python/credentials/test_validation.py index c6020d7..dabbb48 100644 --- a/tests/python/credentials/test_validation.py +++ b/tests/python/credentials/test_validation.py @@ -36,14 +36,22 @@ def _load_json(path: Path) -> dict: def _all_credential_files() -> list[Path]: - """Collect all credential JSON files from examples/ (VCs only, not VPs).""" + """Collect all credential JSON files from examples/ and examples/gaiax/.""" + files: list[Path] = [] if EXAMPLES_DIR.is_dir(): - return sorted( + files.extend( p for p in EXAMPLES_DIR.glob("*.json") if any(t in p.stem for t in ("credential", "receipt", "offering")) ) - return [] + gaiax_dir = EXAMPLES_DIR / "gaiax" + if gaiax_dir.is_dir(): + files.extend( + p + for p in gaiax_dir.glob("*.json") + if any(t in p.stem for t in ("credential", "receipt", "offering")) + ) + return sorted(files) # --------------------------------------------------------------------------- @@ -190,10 +198,8 @@ def test_context_has_domain_classes(self): domain_classes = [ "LegalPersonCredential", "NaturalPersonCredential", - "ServiceOfferingCredential", "LegalPerson", "NaturalPerson", - "ServiceOffering", ] for cls in domain_classes: assert cls in ctx, f"Missing {cls} in gaiax-domain context" @@ -201,17 +207,14 @@ def test_context_has_domain_classes(self): def test_context_has_composition_slots(self): ctx = _load_json(DOMAIN_CONTEXT_PATH).get("@context", {}) assert "gxParticipant" in ctx, "Missing gxParticipant in domain context" - assert "gxServiceOffering" in ctx, "Missing gxServiceOffering in domain context" def test_domain_class_iris_are_prefixed(self): ctx = _load_json(DOMAIN_CONTEXT_PATH).get("@context", {}) domain_classes = [ "LegalPerson", "NaturalPerson", - "ServiceOffering", "LegalPersonCredential", "NaturalPersonCredential", - "ServiceOfferingCredential", ] has_vocab = "@vocab" in ctx for cls in domain_classes: @@ -305,10 +308,8 @@ def test_shacl_has_domain_shapes(self): expected_shapes = [ "harbour:LegalPersonCredential", "harbour:NaturalPersonCredential", - "harbour:ServiceOfferingCredential", "harbour:LegalPerson", "harbour:NaturalPerson", - "harbour:ServiceOffering", ] for shape in expected_shapes: assert ( @@ -321,7 +322,6 @@ def test_credential_shapes_have_required_properties(self): for cred_type in [ "LegalPersonCredential", "NaturalPersonCredential", - "ServiceOfferingCredential", ]: marker = f"harbour:{cred_type} a sh:NodeShape" assert marker in content, f"Missing shape for {cred_type}" From 8bb6bd353ad29a5b874777e5d95b372481aa9b0b Mon Sep 17 00:00:00 2001 From: jdsika Date: Thu, 26 Feb 2026 14:16:49 +0100 Subject: [PATCH 07/78] refactor(linkml): merge core.yaml into harbour.yaml Inline the id slot and type comment from core.yaml into harbour.yaml, eliminating a separate schema file that only defined one slot. Simplifies the schema structure to two files: harbour.yaml (base) and gaiax-domain.yaml (domain layer). Signed-off-by: jdsika --- Makefile | 3 +-- README.md | 4 +--- linkml/core.yaml | 35 ----------------------------------- linkml/gaiax-domain.yaml | 1 - linkml/harbour.yaml | 20 ++++++++++++++++++-- 5 files changed, 20 insertions(+), 43 deletions(-) delete mode 100644 linkml/core.yaml diff --git a/Makefile b/Makefile index 9a85985..4832fa9 100644 --- a/Makefile +++ b/Makefile @@ -51,7 +51,7 @@ endef # LinkML schema files LINKML_SCHEMAS := $(wildcard linkml/*.yaml) -DOMAINS := harbour core gaiax-domain +DOMAINS := harbour gaiax-domain ifdef CI GEN_OWL := gen-owl GEN_SHACL := gen-shacl @@ -207,7 +207,6 @@ validate-shacl: for required in \ "imports/cs/cs.owl.ttl" \ "imports/cred/cred.owl.ttl" \ - "../../artifacts/core/core.owl.ttl" \ "../../artifacts/harbour/harbour.owl.ttl" \ "artifacts/gx/gx.owl.ttl" ; do \ if ! grep -q "$$required" $$tmp_output ; then \ diff --git a/README.md b/README.md index 7026c3f..1759e1a 100644 --- a/README.md +++ b/README.md @@ -250,14 +250,12 @@ tests/ └── typescript/harbour/ # TypeScript tests linkml/ -├── core.yaml # Core types (id, type) ├── harbour.yaml # Harbour base credential framework └── gaiax-domain.yaml # Gaia-X domain layer (participant/service types) artifacts/ # Generated per domain (make generate) ├── harbour/ # Base OWL/SHACL/context -├── gaiax-domain/ # Domain OWL/SHACL/context -└── core/ +└── gaiax-domain/ # Domain OWL/SHACL/context ``` ## Testing diff --git a/linkml/core.yaml b/linkml/core.yaml deleted file mode 100644 index b857ea5..0000000 --- a/linkml/core.yaml +++ /dev/null @@ -1,35 +0,0 @@ -id: https://w3id.org/reachhaven/harbour/core/v1 -name: core - -prefixes: - linkml: https://w3id.org/linkml/ - xsd: http://www.w3.org/2001/XMLSchema# - rdf: http://www.w3.org/1999/02/22-rdf-syntax-ns# - core: https://w3id.org/reachhaven/harbour/core/v1/ - -default_prefix: core -default_range: string - -imports: - - linkml:types - -slots: - id: - # Spec: VCDM2 §4.4 — "id" is OPTIONAL on VC/VP/credentialSubject. - # If present, MUST be a URL (incl. DIDs, urn:uuid:). - # Maps to JSON-LD @id. - # Spec: DID-Core §3.1 — DID is a URI scheme. - description: The stable identifier for the entity (becomes @id in JSON-LD). - identifier: true - range: uri - - # NOTE: "type" is intentionally NOT modeled here. - # Spec: VCDM2 §4.5 — "type" MUST be present, maps to @type. Values MUST be - # terms or absolute URL strings resolvable via @context. - # W3C VC v2 context defines "type": "@type", which correctly maps JSON "type" - # to rdf:type IRIs. Declaring a LinkML slot with slot_uri: rdf:type would - # generate a JSON-LD context entry that overrides the W3C alias, turning type - # values into xsd:anyURI literals instead of IRIs. It would also generate - # SHACL sh:property constraints on rdf:type with sh:datatype/sh:nodeKind - # that reject IRI values. SHACL type-based targeting (sh:targetClass) and - # sh:ignoredProperties (rdf:type) handle rdf:type correctly without this. diff --git a/linkml/gaiax-domain.yaml b/linkml/gaiax-domain.yaml index ddaca2c..00d9042 100644 --- a/linkml/gaiax-domain.yaml +++ b/linkml/gaiax-domain.yaml @@ -10,7 +10,6 @@ description: > prefixes: linkml: https://w3id.org/linkml/ harbour: https://w3id.org/reachhaven/harbour/credentials/v1/ - core: https://w3id.org/reachhaven/harbour/core/v1/ xsd: http://www.w3.org/2001/XMLSchema# cred: https://www.w3.org/2018/credentials# schema: http://schema.org/ diff --git a/linkml/harbour.yaml b/linkml/harbour.yaml index ffa0e0b..6afe338 100644 --- a/linkml/harbour.yaml +++ b/linkml/harbour.yaml @@ -11,7 +11,6 @@ description: > prefixes: linkml: https://w3id.org/linkml/ harbour: https://w3id.org/reachhaven/harbour/credentials/v1/ - core: https://w3id.org/reachhaven/harbour/core/v1/ xsd: http://www.w3.org/2001/XMLSchema# cs: https://www.w3.org/ns/credentials/status# cred: https://www.w3.org/2018/credentials# @@ -22,9 +21,26 @@ default_range: string imports: - linkml:types - - ./core slots: + # --- Identity Slots --- + # Spec: VCDM2 §4.4 — "id" is OPTIONAL on VC/VP/credentialSubject. + # If present, MUST be a URL (incl. DIDs, urn:uuid:). + # Maps to JSON-LD @id. + # Spec: DID-Core §3.1 — DID is a URI scheme. + id: + description: The stable identifier for the entity (becomes @id in JSON-LD). + identifier: true + range: uri + + # NOTE: "type" is intentionally NOT modeled as a slot. + # Spec: VCDM2 §4.5 — "type" MUST be present, maps to @type. Values MUST be + # terms or absolute URL strings resolvable via @context. + # W3C VC v2 context defines "type": "@type", which correctly maps JSON "type" + # to rdf:type IRIs. Declaring a LinkML slot with slot_uri: rdf:type would + # generate a JSON-LD context entry that overrides the W3C alias, turning type + # values into xsd:anyURI literals instead of IRIs. + # --- DID Slots --- # Spec: DID-Core §4.2 — controller is a URI or set of URIs. # In practice always a DID (did:web:..., did:key:..., etc.). From 64a99c57f7c483c7b74dbe6f2be2fbcd33e9568f Mon Sep 17 00:00:00 2001 From: jdsika Date: Thu, 26 Feb 2026 14:29:28 +0100 Subject: [PATCH 08/78] chore(ci): use Makefile targets in CI workflows and ASCII-only Makefile CI jobs now delegate to Makefile targets (make lint, make test, etc.) instead of inlining shell commands. Added make test-interop target for cross-runtime tests. Replaced all emoji in Makefile with ASCII text. Signed-off-by: jdsika --- .github/workflows/ci.yml | 42 ++++------- Makefile | 156 +++++++++++++++++++++------------------ 2 files changed, 99 insertions(+), 99 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index edf4cb3..7cbd505 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,10 +24,10 @@ jobs: cache: 'pip' - name: Install dependencies - run: python3 -m pip install -e ".[dev]" + run: make install-dev - name: Run pre-commit - run: pre-commit run --all-files + run: make lint lint-ts: name: Lint (TypeScript) @@ -42,13 +42,8 @@ jobs: - name: Enable Corepack run: corepack enable - - name: Install dependencies - working-directory: src/typescript/harbour - run: yarn install --immutable - - - name: Type check - working-directory: src/typescript/harbour - run: yarn lint + - name: Lint TypeScript + run: make lint-ts generate-validate: name: Generate & Validate @@ -64,9 +59,10 @@ jobs: cache: 'pip' - name: Install dependencies - run: | - python3 -m pip install -e ".[dev]" linkml - python3 -m pip install -e "./submodules/ontology-management-base" + run: make install-dev + + - name: Install ontology-management-base + run: make submodule-setup - name: Generate artifacts run: make generate @@ -89,10 +85,10 @@ jobs: cache: 'pip' - name: Install dependencies - run: python3 -m pip install -e ".[dev]" + run: make install-dev - name: Run tests - run: PYTHONPATH=src/python:$PYTHONPATH pytest tests/ -v --tb=short --cov + run: make test test-ts: name: Test (TypeScript) @@ -107,13 +103,8 @@ jobs: - name: Enable Corepack run: corepack enable - - name: Install dependencies - working-directory: src/typescript/harbour - run: yarn install --immutable - - name: Run tests - working-directory: src/typescript/harbour - run: yarn test + run: make test-ts test-interop: name: Cross-Runtime Interop @@ -135,15 +126,10 @@ jobs: run: corepack enable - name: Install Python dependencies - run: python3 -m pip install -e ".[dev]" - - - name: Install TypeScript dependencies - working-directory: src/typescript/harbour - run: yarn install --immutable + run: make install-dev - name: Build TypeScript - working-directory: src/typescript/harbour - run: yarn build + run: make build-ts - name: Run interop tests - run: PYTHONPATH=src/python:$PYTHONPATH pytest tests/interop/test_cross_runtime.py -v --tb=short + run: make test-interop diff --git a/Makefile b/Makefile index 4832fa9..3bf0d8d 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Harbour Credentials Makefile # ============================ -.PHONY: setup install install-dev submodule-setup ts-bootstrap generate validate validate-shacl lint format test test-cov test-ts build-ts lint-ts test-all all clean help +.PHONY: setup install install-dev submodule-setup ts-bootstrap generate validate validate-shacl lint format test test-cov test-ts test-interop build-ts lint-ts test-all all clean help TS_DIR := src/typescript/harbour OMB_SUBMODULE_DIR := submodules/ontology-management-base @@ -31,7 +31,7 @@ PYTEST := $(PYTHON) -m pytest define check_dev_setup @if [ -z "$$CI" ] && [ ! -x "$(PYTHON)" ]; then \ echo ""; \ - echo "❌ Development environment not set up."; \ + echo "ERROR: Development environment not set up."; \ echo ""; \ echo "Please run first:"; \ echo " make setup"; \ @@ -40,7 +40,7 @@ define check_dev_setup fi @if ! $(PYTHON) -c "import linkml" 2>/dev/null; then \ echo ""; \ - echo "❌ Dev dependencies not installed."; \ + echo "ERROR: Dev dependencies not installed."; \ echo ""; \ echo "Please run:"; \ echo " make setup"; \ @@ -64,113 +64,120 @@ endif # Default target help: - @echo "🔧 Showing available commands..." @echo "Harbour Credentials - Available Commands" @echo "" @echo "Installation:" - @echo " make setup - Create venv, install dev dependencies, setup ontology submodule, and bootstrap TypeScript" - @echo " make install - Install package (user mode)" - @echo " make install-dev - Install with dev dependencies + pre-commit" + @echo " make setup - Create venv, install dev dependencies, setup ontology submodule, and bootstrap TypeScript" + @echo " make install - Install package (user mode)" + @echo " make install-dev - Install with dev dependencies + pre-commit" @echo " make ts-bootstrap - Enable corepack and install TypeScript dependencies" @echo "" @echo "Artifacts:" - @echo " make generate - Generate OWL/SHACL/context from LinkML" - @echo " make validate - Validate credentials against SHACL shapes" - @echo " make validate-shacl - Run SHACL conformance on examples (via ontology-management-base)" + @echo " make generate - Generate OWL/SHACL/context from LinkML" + @echo " make validate - Validate credentials against SHACL shapes" + @echo " make validate-shacl - Run SHACL conformance on examples (via ontology-management-base)" @echo "" @echo "Linting:" - @echo " make lint - Run pre-commit checks (Python)" - @echo " make lint-ts - Run TypeScript linting" - @echo " make format - Format Python code with black/isort" + @echo " make lint - Run pre-commit checks (Python)" + @echo " make lint-ts - Run TypeScript linting" + @echo " make format - Format Python code with black/isort" @echo "" @echo "Testing:" - @echo " make test - Run Python pytest suite" - @echo " make test-ts - Run TypeScript vitest suite" - @echo " make test-all - Run Python tests + SHACL conformance + TypeScript tests" - @echo " make test-cov - Run Python tests with coverage report" + @echo " make test - Run Python pytest suite" + @echo " make test-ts - Run TypeScript vitest suite" + @echo " make test-interop - Run cross-runtime interop tests (Python + TypeScript)" + @echo " make test-all - Run Python tests + SHACL conformance + TypeScript tests" + @echo " make test-cov - Run Python tests with coverage report" @echo "" @echo "TypeScript:" - @echo " make build-ts - Build TypeScript package" + @echo " make build-ts - Build TypeScript package" @echo "" @echo "Cleaning:" - @echo " make clean - Remove build artifacts and caches" - @echo "" - @echo "✅ Help displayed" + @echo " make clean - Remove build artifacts and caches" # Create virtual environment and install dependencies setup: - @echo "🔧 Setting up development environment..." - @echo "🔧 Checking Python virtual environment and dependencies..." + @echo "Setting up development environment..." + @echo "Checking Python virtual environment and dependencies..." @set -e; \ if [ ! -x "$(PYTHON)" ]; then \ - echo "🔧 Python virtual environment not found; bootstrapping..."; \ + echo "Python virtual environment not found; bootstrapping..."; \ $(MAKE) --no-print-directory $(VENV)/bin/activate; \ elif $(PYTHON) -c "import pre_commit, linkml" >/dev/null 2>&1; then \ - echo "✅ Python virtual environment and dependencies are ready at $(VENV)"; \ + echo "OK: Python virtual environment and dependencies are ready at $(VENV)"; \ else \ - echo "🔧 Python virtual environment found but dependencies are missing; bootstrapping..."; \ + echo "Python virtual environment found but dependencies are missing; bootstrapping..."; \ $(MAKE) --no-print-directory -B $(VENV)/bin/activate; \ fi @$(MAKE) --no-print-directory submodule-setup @$(MAKE) --no-print-directory ts-bootstrap @echo "" - @echo "✅ Setup complete. Activate with: source $(VENV)/bin/activate" + @echo "Setup complete. Activate with: source $(VENV)/bin/activate" $(VENV)/bin/python3: - @echo "🔧 Creating Python virtual environment at $(VENV)..." + @echo "Creating Python virtual environment at $(VENV)..." @$(BOOTSTRAP_PYTHON) -m venv $(VENV) @$(PIP) install --upgrade pip - @echo "✅ Python virtual environment ready" + @echo "OK: Python virtual environment ready" $(VENV)/bin/activate: $(VENV)/bin/python3 - @echo "🔧 Installing Python dependencies..." + @echo "Installing Python dependencies..." @$(PIP) install -e ".[dev]" @$(PIP) install linkml @$(PRECOMMIT) install - @echo "✅ Python development environment ready" + @echo "OK: Python development environment ready" # Setup ontology-management-base submodule using the same active venv submodule-setup: - @echo "🔧 Setting up ontology-management-base submodule..." + @echo "Setting up ontology-management-base submodule..." @set -e; \ - if [ -f "$(OMB_SUBMODULE_DIR)/Makefile" ]; then \ + if [ -f "$(OMB_SUBMODULE_DIR)/setup.py" ] || [ -f "$(OMB_SUBMODULE_DIR)/pyproject.toml" ]; then \ + $(PIP) install -e "$(OMB_SUBMODULE_DIR)"; \ + echo "OK: ontology-management-base submodule setup complete"; \ + elif [ -f "$(OMB_SUBMODULE_DIR)/Makefile" ]; then \ $(MAKE) --no-print-directory -C $(OMB_SUBMODULE_DIR) setup \ VENV="$(abspath $(VENV))" \ PYTHON="$(abspath $(PYTHON))" \ PIP="$(abspath $(PYTHON)) -m pip" \ PRECOMMIT="$(abspath $(PYTHON)) -m pre_commit" \ PYTEST="$(abspath $(PYTHON)) -m pytest"; \ - echo "✅ ontology-management-base submodule setup complete"; \ + echo "OK: ontology-management-base submodule setup complete"; \ else \ - echo "⚠️ Skipping ontology-management-base submodule setup (Makefile not found)"; \ + echo "WARNING: Skipping ontology-management-base submodule setup (not found)"; \ fi # Bootstrap TypeScript toolchain ts-bootstrap: - @echo "🔧 Bootstrapping TypeScript dependencies..." + @echo "Bootstrapping TypeScript dependencies..." @cd $(TS_DIR) && corepack enable && yarn install - @echo "✅ TypeScript bootstrap complete" + @echo "OK: TypeScript bootstrap complete" # Install package (user mode) install: - @echo "🔧 Installing package in editable mode..." + @echo "Installing package in editable mode..." +ifndef CI @$(MAKE) --no-print-directory $(VENV)/bin/python3 +endif @$(PIP) install -e . - @echo "✅ Package installation complete" + @echo "OK: Package installation complete" -# Install with dev dependencies +# Install with dev dependencies (works in CI without venv creation) install-dev: - @echo "🔧 Installing development dependencies..." + @echo "Installing development dependencies..." +ifndef CI @$(MAKE) --no-print-directory $(VENV)/bin/python3 +endif @$(PIP) install -e ".[dev]" @$(PIP) install linkml +ifndef CI @$(PRECOMMIT) install - @echo "✅ Development dependencies installed" +endif + @echo "OK: Development dependencies installed" # Generate artifacts from LinkML models generate: $(call check_dev_setup) - @echo "🔧 Generating artifacts from LinkML schemas..." + @echo "Generating artifacts from LinkML schemas..." @for domain in $(DOMAINS); do \ echo " Processing $$domain..."; \ mkdir -p artifacts/$$domain; \ @@ -179,19 +186,19 @@ generate: $(GEN_JSONLD_CONTEXT) linkml/$$domain.yaml > artifacts/$$domain/$$domain.context.jsonld; \ done @echo "" - @echo "✅ Artifacts generated in artifacts/" + @echo "OK: Artifacts generated in artifacts/" # Validate credentials against generated SHACL shapes and JSON-LD syntax validate: $(call check_dev_setup) - @echo "🔧 Validating harbour credentials..." + @echo "Validating harbour credentials..." @PYTHONPATH=src/python:$$PYTHONPATH $(PYTEST) tests/python/credentials/test_validation.py -v - @echo "✅ Validation complete" + @echo "OK: Validation complete" # Validate example credentials against SHACL shapes via ontology-management-base validate-shacl: $(call check_dev_setup) - @echo "🔧 Running SHACL data conformance check on examples..." + @echo "Running SHACL data conformance check on examples..." @cd $(OMB_SUBMODULE_DIR) && \ tmp_output=$$(mktemp) && \ $(PYTHON) -m src.tools.validators.validation_suite \ @@ -210,86 +217,93 @@ validate-shacl: "../../artifacts/harbour/harbour.owl.ttl" \ "artifacts/gx/gx.owl.ttl" ; do \ if ! grep -q "$$required" $$tmp_output ; then \ - echo "❌ Required ontology not loaded by validation suite: $$required" >&2 ; \ + echo "ERROR: Required ontology not loaded by validation suite: $$required" >&2 ; \ rm -f $$tmp_output ; \ exit 1 ; \ fi ; \ done ; \ rm -f $$tmp_output - @echo "✅ SHACL validation complete" + @echo "OK: SHACL validation complete" # Run pre-commit hooks on all files lint: $(call check_dev_setup) - @echo "🔧 Running pre-commit checks..." + @echo "Running pre-commit checks..." @$(PYTHON) -m pre_commit run --all-files - @echo "✅ Pre-commit checks complete" + @echo "OK: Pre-commit checks complete" # Auto-format code format: $(call check_dev_setup) - @echo "🔧 Formatting Python code..." + @echo "Formatting Python code..." @$(PYTHON) -m black src/python/ tests/ @$(PYTHON) -m isort src/python/ tests/ - @echo "✅ Python formatting complete" + @echo "OK: Python formatting complete" # Run tests test: $(call check_dev_setup) - @echo "🔧 Running Python tests..." + @echo "Running Python tests..." @PYTHONPATH=src/python:$$PYTHONPATH $(PYTEST) tests/ -v - @echo "✅ Python tests complete" + @echo "OK: Python tests complete" # Run tests with coverage test-cov: $(call check_dev_setup) - @echo "🔧 Running Python tests with coverage..." + @echo "Running Python tests with coverage..." @PYTHONPATH=src/python:$$PYTHONPATH $(PYTEST) tests/ --cov=src/python/harbour --cov=src/python/credentials --cov-report=html --cov-report=term - @echo "✅ Coverage run complete" + @echo "OK: Coverage run complete" # TypeScript targets build-ts: - @echo "🔧 Building TypeScript..." + @echo "Building TypeScript..." @cd $(TS_DIR) && corepack enable && yarn install && yarn build - @echo "✅ TypeScript build complete" + @echo "OK: TypeScript build complete" test-ts: - @echo "🔧 Running TypeScript tests..." + @echo "Running TypeScript tests..." @cd $(TS_DIR) && corepack enable && yarn test - @echo "✅ TypeScript tests complete" + @echo "OK: TypeScript tests complete" lint-ts: - @echo "🔧 Linting TypeScript..." + @echo "Linting TypeScript..." @cd $(TS_DIR) && corepack enable && yarn lint - @echo "✅ TypeScript lint complete" + @echo "OK: TypeScript lint complete" + +# Cross-runtime interop tests (requires both Python + TypeScript) +test-interop: + $(call check_dev_setup) + @echo "Running cross-runtime interop tests..." + @PYTHONPATH=src/python:$$PYTHONPATH $(PYTEST) tests/interop/ -v + @echo "OK: Interop tests complete" # Compound targets all: - @echo "🔧 Running default quality pipeline (lint + test)..." + @echo "Running default quality pipeline (lint + test)..." @$(MAKE) --no-print-directory lint @$(MAKE) --no-print-directory test - @echo "✅ Default quality pipeline complete" + @echo "OK: Default quality pipeline complete" # Run all tests (Python + TypeScript) test-all: - @echo "🔧 Running all tests (Python + SHACL + TypeScript)..." + @echo "Running all tests (Python + SHACL + TypeScript)..." @$(MAKE) --no-print-directory build-ts @$(MAKE) --no-print-directory test @$(MAKE) --no-print-directory validate-shacl @$(MAKE) --no-print-directory test-ts - @echo "✅ All tests complete" + @echo "OK: All tests complete" # Clean generated files clean: - @echo "🔧 Cleaning generated files and caches..." + @echo "Cleaning generated files and caches..." @if [ "$(VENV)" = ".venv" ]; then \ rm -rf $(VENV); \ - echo "✅ Removed local virtual environment $(VENV)"; \ + echo "OK: Removed local virtual environment $(VENV)"; \ else \ - echo "✅ Skipping shared virtual environment $(VENV)"; \ + echo "OK: Skipping shared virtual environment $(VENV)"; \ fi @rm -rf build/ dist/ *.egg-info/ @rm -rf .pytest_cache .coverage htmlcov @find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true @find . -type f -name "*.pyc" -delete 2>/dev/null || true - @echo "✅ Cleaned" + @echo "OK: Cleaned" From b207f510aad94fd052283ad0cbeba9df0d8b7beb Mon Sep 17 00:00:00 2001 From: jdsika Date: Thu, 26 Feb 2026 14:35:48 +0100 Subject: [PATCH 09/78] fix(ci): add yarn install to TS Makefile targets lint-ts and test-ts were missing yarn install, causing CI failures where dependencies hadn't been installed yet. Locally this worked because make setup had already run yarn install. Now all TS targets (build-ts, test-ts, lint-ts) consistently run corepack enable + yarn install before execution. Removed redundant corepack enable steps from CI workflow since Makefile handles it. Signed-off-by: jdsika --- .github/workflows/ci.yml | 9 --------- Makefile | 4 ++-- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cbd505..aac6b36 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,9 +39,6 @@ jobs: with: node-version: "22" - - name: Enable Corepack - run: corepack enable - - name: Lint TypeScript run: make lint-ts @@ -100,9 +97,6 @@ jobs: with: node-version: "22" - - name: Enable Corepack - run: corepack enable - - name: Run tests run: make test-ts @@ -122,9 +116,6 @@ jobs: with: node-version: "22" - - name: Enable Corepack - run: corepack enable - - name: Install Python dependencies run: make install-dev diff --git a/Makefile b/Makefile index 3bf0d8d..c68080d 100644 --- a/Makefile +++ b/Makefile @@ -262,12 +262,12 @@ build-ts: test-ts: @echo "Running TypeScript tests..." - @cd $(TS_DIR) && corepack enable && yarn test + @cd $(TS_DIR) && corepack enable && yarn install && yarn test @echo "OK: TypeScript tests complete" lint-ts: @echo "Linting TypeScript..." - @cd $(TS_DIR) && corepack enable && yarn lint + @cd $(TS_DIR) && corepack enable && yarn install && yarn lint @echo "OK: TypeScript lint complete" # Cross-runtime interop tests (requires both Python + TypeScript) From e72f10a253d6b380775099f0a87e489811e42d1e Mon Sep 17 00:00:00 2001 From: felix hoops <9974641+flhps@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:44:13 +0100 Subject: [PATCH 10/78] fix(makefile): use abspath for PYTHON in validate-shacl target cd into OMB_SUBMODULE_DIR before invoking $(PYTHON), so the relative path .venv/bin/python3 no longer resolves. Use $(abspath $(PYTHON)) to produce an absolute path, consistent with how submodule-setup already passes PYTHON to sub-makes. Signed-off-by: felix hoops <9974641+flhps@users.noreply.github.com> --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index c68080d..3f049b1 100644 --- a/Makefile +++ b/Makefile @@ -201,7 +201,7 @@ validate-shacl: @echo "Running SHACL data conformance check on examples..." @cd $(OMB_SUBMODULE_DIR) && \ tmp_output=$$(mktemp) && \ - $(PYTHON) -m src.tools.validators.validation_suite \ + $(abspath $(PYTHON)) -m src.tools.validators.validation_suite \ --run check-data-conformance \ --data-paths ../../examples/ ../../examples/gaiax/ ../../tests/validation-probe/ontology-loading-probe.json \ --artifacts ../../artifacts > $$tmp_output 2>&1 ; \ From 5a8a2b3701d44f689b696e1c90e51fe1c4a89cc6 Mon Sep 17 00:00:00 2001 From: felix hoops <9974641+flhps@users.noreply.github.com> Date: Thu, 26 Feb 2026 19:30:27 +0100 Subject: [PATCH 11/78] docs(readme): consolidate testing section and fix commands Remove duplicate 'Run Tests' subsection from under Validating Credentials. Consolidate into a single Testing section using make targets throughout: make test-ts instead of yarn, make test-interop instead of raw pytest. Note make build-ts as a prerequisite for TypeScript and interop tests. Signed-off-by: felix hoops <9974641+flhps@users.noreply.github.com> --- README.md | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 1759e1a..5f20c1f 100644 --- a/README.md +++ b/README.md @@ -171,16 +171,6 @@ make validate-shacl make validate ``` -### Run Tests - -```bash -# Run Python tests only -make test - -# Run full pipeline (Python + SHACL conformance via validation_suite.py + TypeScript) -make test-all -``` - ## CLI Usage All Python modules have CLI entry points: @@ -264,13 +254,17 @@ artifacts/ # Generated per domain (make generate) # Python tests make test -# TypeScript tests -cd src/typescript/harbour && yarn test +# TypeScript tests (requires make build-ts first) +make build-ts +make test-ts -# Cross-runtime interop tests -PYTHONPATH=src/python:$PYTHONPATH pytest tests/interop/test_cross_runtime.py -v +# Cross-runtime interop tests (requires make build-ts first) +make test-interop + +# Full pipeline: Python + SHACL conformance + TypeScript (builds TS automatically) +make test-all -# All tests with coverage +# Python tests with coverage make test-cov # Lint From 908ae21e9418c7c15aad3b097bd1f2655deec1dc Mon Sep 17 00:00:00 2001 From: jdsika Date: Mon, 2 Mar 2026 16:10:38 +0100 Subject: [PATCH 12/78] refactor(linkml): rename schema files to harbour-core-credential and harbour-gx-credential MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarify the separation between infrastructure types (HarbourCredential, CRSet, Evidence, DID) and Gaia-X concrete types (LegalPersonCredential, NaturalPersonCredential, gxParticipant) by renaming the schema files. - linkml/harbour.yaml -> linkml/harbour-core-credential.yaml - linkml/gaiax-domain.yaml -> linkml/harbour-gx-credential.yaml - Makefile DOMAINS updated to match new filenames - Test artifact paths and doc references updated - Add BMW legal-person-credential example for IRI reference testing - RDF namespace/prefix stays harbour: — no URI changes Signed-off-by: jdsika --- Makefile | 4 +- README.md | 12 +-- docs/architecture.md | 2 +- docs/decisions/001-vc-securing-mechanism.md | 101 ++++++++++-------- .../002-dual-runtime-architecture.md | 2 +- docs/guide/evidence.md | 2 +- .../gaiax/legal-person-credential-bmw.json | 63 +++++++++++ ...bour.yaml => harbour-core-credential.yaml} | 2 +- ...domain.yaml => harbour-gx-credential.yaml} | 4 +- tests/python/credentials/test_validation.py | 30 +++--- 10 files changed, 147 insertions(+), 75 deletions(-) create mode 100644 examples/gaiax/legal-person-credential-bmw.json rename linkml/{harbour.yaml => harbour-core-credential.yaml} (99%) rename linkml/{gaiax-domain.yaml => harbour-gx-credential.yaml} (98%) diff --git a/Makefile b/Makefile index 3f049b1..3bf6820 100644 --- a/Makefile +++ b/Makefile @@ -51,7 +51,7 @@ endef # LinkML schema files LINKML_SCHEMAS := $(wildcard linkml/*.yaml) -DOMAINS := harbour gaiax-domain +DOMAINS := harbour-core-credential harbour-gx-credential ifdef CI GEN_OWL := gen-owl GEN_SHACL := gen-shacl @@ -214,7 +214,7 @@ validate-shacl: for required in \ "imports/cs/cs.owl.ttl" \ "imports/cred/cred.owl.ttl" \ - "../../artifacts/harbour/harbour.owl.ttl" \ + "../../artifacts/harbour-core-credential/harbour-core-credential.owl.ttl" \ "artifacts/gx/gx.owl.ttl" ; do \ if ! grep -q "$$required" $$tmp_output ; then \ echo "ERROR: Required ontology not loaded by validation suite: $$required" >&2 ; \ diff --git a/README.md b/README.md index 5f20c1f..ad347ec 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ const payload = await verifyVcJose(jwt, publicKey); ## Harbour Credential Types -Harbour provides a base credential framework (`harbour.yaml`) with **skeleton credentials** that define the minimum required structure. A Gaia-X domain layer (`gaiax-domain.yaml`) extends the skeletons with participant types using a **composition pattern**: +Harbour provides a base credential framework (`harbour-core-credential.yaml`) with **skeleton credentials** that define the minimum required structure. A Gaia-X domain layer (`harbour-gx-credential.yaml`) extends the skeletons with participant types using a **composition pattern**: | Credential Type | Subject Type | Composition Slot | Gaia-X Inner Type | | ----------------------------------- | ------------------------- | --------------------- | --------------------- | @@ -240,12 +240,12 @@ tests/ └── typescript/harbour/ # TypeScript tests linkml/ -├── harbour.yaml # Harbour base credential framework -└── gaiax-domain.yaml # Gaia-X domain layer (participant/service types) +├── harbour-core-credential.yaml # Harbour base credential framework +└── harbour-gx-credential.yaml # Gaia-X domain layer (participant/service types) -artifacts/ # Generated per domain (make generate) -├── harbour/ # Base OWL/SHACL/context -└── gaiax-domain/ # Domain OWL/SHACL/context +artifacts/ # Generated per domain (make generate) +├── harbour-core-credential/ # Base OWL/SHACL/context +└── harbour-gx-credential/ # Domain OWL/SHACL/context ``` ## Testing diff --git a/docs/architecture.md b/docs/architecture.md index 8759adc..3c13b22 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ harbour-credentials/ │ ├── interop/ # Cross-runtime interoperability tests │ ├── python/ # Python tests (harbour + credentials) │ └── typescript/harbour/ # TypeScript tests -├── linkml/ # LinkML schemas (harbour.yaml, core.yaml, gaiax-domain.yaml) +├── linkml/ # LinkML schemas (harbour-core-credential.yaml, harbour-gx-credential.yaml) └── artifacts/ # Generated OWL/SHACL/context (per domain) ``` diff --git a/docs/decisions/001-vc-securing-mechanism.md b/docs/decisions/001-vc-securing-mechanism.md index e0d569a..d2542e0 100644 --- a/docs/decisions/001-vc-securing-mechanism.md +++ b/docs/decisions/001-vc-securing-mechanism.md @@ -70,18 +70,18 @@ CBOR-based credential format for mobile documents. ## Comparison Matrix -| Aspect | Data Integrity | VC-JOSE-COSE | SD-JWT-VC | mdoc | -|--------|---------------|--------------|-----------|------| -| **EUDI mandatory** | No | No | **Yes** | Yes | -| **Gaia-X current** | No | **Yes** | Roadmap | No | -| **HAIP profile** | No | No | **Yes** | Yes | -| **OIDC4VP support** | Partial | Yes | **Yes** | Yes | -| **Selective disclosure** | Complex | Via SD-JWT | **Native** | Yes | -| **W3C VCDM 2.0** | Yes | Yes | **No** | No | -| **JSON-LD / SHACL** | Yes | Yes | No | No | -| **Standard JWT libs** | No | Yes | Specialized | No | -| **JS library** | @digitalbazaar | npm `jose` | `@sd-jwt/sd-jwt-vc` | — | -| **Python library** | None mature | `joserfc` | `sd-jwt-python` | — | +| Aspect | Data Integrity | VC-JOSE-COSE | SD-JWT-VC | mdoc | +| ------------------------ | -------------- | ------------ | ------------------- | ---- | +| **EUDI mandatory** | No | No | **Yes** | Yes | +| **Gaia-X current** | No | **Yes** | Roadmap | No | +| **HAIP profile** | No | No | **Yes** | Yes | +| **OIDC4VP support** | Partial | Yes | **Yes** | Yes | +| **Selective disclosure** | Complex | Via SD-JWT | **Native** | Yes | +| **W3C VCDM 2.0** | Yes | Yes | **No** | No | +| **JSON-LD / SHACL** | Yes | Yes | No | No | +| **Standard JWT libs** | No | Yes | Specialized | No | +| **JS library** | @digitalbazaar | npm `jose` | `@sd-jwt/sd-jwt-vc` | — | +| **Python library** | None mature | `joserfc` | `sd-jwt-python` | — | ## Critical Findings @@ -133,60 +133,60 @@ Support **two complementary formats**, serving different purposes: ### Primary: SD-JWT-VC (IETF) — for EUDI / OIDC4VP -| Aspect | Choice | -|--------|--------| -| Format | SD-JWT-VC (compact serialization) | -| Algorithm | **ES256** (ECDSA P-256) — HAIP mandatory minimum | -| Key resolution | X.509 via `x5c` header (EUDI) + `did:webs` (Gaia-X) | -| Selective disclosure | Native SD-JWT | -| Holder binding | `cnf` claim with proof-of-possession | -| Status | `status_list` (Token Status List) | -| JS library | `@sd-jwt/sd-jwt-vc` | -| Python library | `sd-jwt-python` (OpenWallet Foundation) | -| Media type | `application/dc+sd-jwt` | +| Aspect | Choice | +| -------------------- | --------------------------------------------------- | +| Format | SD-JWT-VC (compact serialization) | +| Algorithm | **ES256** (ECDSA P-256) — HAIP mandatory minimum | +| Key resolution | X.509 via `x5c` header (EUDI) + `did:webs` (Gaia-X) | +| Selective disclosure | Native SD-JWT | +| Holder binding | `cnf` claim with proof-of-possession | +| Status | `status_list` (Token Status List) | +| JS library | `@sd-jwt/sd-jwt-vc` | +| Python library | `sd-jwt-python` (OpenWallet Foundation) | +| Media type | `application/dc+sd-jwt` | ### Secondary: W3C VC-JOSE-COSE — for Gaia-X current + schema validation -| Aspect | Choice | -|--------|--------| -| Format | Compact JWS (`header.payload.signature`) | -| Algorithm | **ES256** (consistent with SD-JWT-VC) | -| JWT header | `{"alg": "ES256", "typ": "vc+jwt"}` | -| Payload | Full W3C VCDM 2.0 JSON-LD | +| Aspect | Choice | +| -------------- | -------------------------------------------- | +| Format | Compact JWS (`header.payload.signature`) | +| Algorithm | **ES256** (consistent with SD-JWT-VC) | +| JWT header | `{"alg": "ES256", "typ": "vc+jwt"}` | +| Payload | Full W3C VCDM 2.0 JSON-LD | | Key resolution | `did:webs` (Gaia-X) + `x5c` (EUDI alignment) | -| JS library | npm `jose` | -| Python library | `joserfc` | +| JS library | npm `jose` | +| Python library | `joserfc` | ### Key Management Migration: Ed25519 → P-256 -| Aspect | Current | Target | -|--------|---------|--------| -| Algorithm | Ed25519 (EdDSA) | **P-256 (ES256)** | -| Key format | JWK OKP/Ed25519 | **JWK EC/P-256** | -| DID method | `did:key:z6Mk...` | `did:key:zDn...` (P-256) + `did:webs` | -| Certificates | None | X.509 chains via `x5c` | +| Aspect | Current | Target | +| ------------ | ----------------- | ------------------------------------- | +| Algorithm | Ed25519 (EdDSA) | **P-256 (ES256)** | +| Key format | JWK OKP/Ed25519 | **JWK EC/P-256** | +| DID method | `did:key:z6Mk...` | `did:key:zDn...` (P-256) + `did:webs` | +| Certificates | None | X.509 chains via `x5c` | Ed25519 is also supported for testing, but **ES256 MUST be the default** for EUDI compliance. ## Relationship Between Formats ``` - ┌─────────────────────────────┐ - │ LinkML Schema Definition │ - │ (harbour.yaml, etc.) │ - └──────────┬──────────────────┘ + ┌────────────────────────────────────────┐ + │ LinkML Schema Definition │ + │ (harbour-core-credential.yaml, etc.) │ + └────────────────┬───────────────────────┘ │ generates ┌──────────▼──────────────────┐ - │ JSON-LD Context + SHACL │ - │ (schema validation layer) │ + │ JSON-LD Context + SHACL │ + │ (schema validation layer) │ └──────────┬──────────────────┘ │ validates ┌────────────────┼────────────────┐ ▼ ▼ ▼ ┌─────────────────┐ ┌──────────┐ ┌──────────────┐ - │ Example VCs │ │ Signed │ │ SD-JWT-VC │ - │ (JSON-LD) │ │ VC-JWT │ │ (EUDI) │ - │ development/test │ │ (Gaia-X) │ │ production │ + │ Example VCs │ │ Signed │ │ SD-JWT-VC │ + │ (JSON-LD) │ │ VC-JWT │ │ (EUDI) │ + │ development/test│ │ (Gaia-X) │ │ production │ └─────────────────┘ └──────────┘ └──────────────┘ ``` @@ -199,6 +199,7 @@ A credential can exist in multiple formats simultaneously. The mapping from SHAC ## Consequences ### Positive + - EUDI wallet compatible (SD-JWT-VC + ES256 + x5c) - Gaia-X compatible (VC-JWT + did:webs) - Selective disclosure for privacy-sensitive fields @@ -206,12 +207,14 @@ A credential can exist in multiple formats simultaneously. The mapping from SHAC - Future-proof (SD-JWT-VC is the regulatory direction) ### Negative + - Two signing formats to maintain (SD-JWT-VC + VC-JOSE-COSE) - ES256 is slower than Ed25519 (negligible for credential operations) - X.509 certificate management adds operational complexity - SD-JWT-VC mapping from JSON-LD needs explicit definition ### Migration Path (completed) + 1. ~~Add ES256 (P-256) key generation alongside Ed25519~~ 2. ~~Implement VC-JOSE-COSE signer/verifier (standard JWT with ES256)~~ 3. ~~Implement SD-JWT-VC signer/verifier using OpenWallet Foundation libraries~~ @@ -223,11 +226,13 @@ A credential can exist in multiple formats simultaneously. The mapping from SHAC ## References ### W3C + - [W3C VC Data Model v2](https://www.w3.org/TR/vc-data-model-2.0/) - [W3C VC-JOSE-COSE](https://www.w3.org/TR/vc-jose-cose/) - [W3C VC Data Integrity](https://www.w3.org/TR/vc-data-integrity/) ### IETF + - [SD-JWT-VC (draft-14)](https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/) - [SD-JWT (RFC 9901)](https://datatracker.ietf.org/doc/rfc9901/) - [RFC 7515 — JWS](https://www.rfc-editor.org/rfc/rfc7515) @@ -235,21 +240,25 @@ A credential can exist in multiple formats simultaneously. The mapping from SHAC - [RFC 9864 — EdDSA deprecation](https://www.rfc-editor.org/rfc/rfc9864) ### EUDI / eIDAS 2.0 + - [EUDI Architecture Reference Framework](https://github.com/eu-digital-identity-wallet/eudi-doc-architecture-and-reference-framework) - [EUDI ARF latest](https://eudi.dev/latest/architecture-and-reference-framework-main/) - [PID Rulebook](https://github.com/eu-digital-identity-wallet/eudi-doc-attestation-rulebooks-catalog) - [EUDI Standards Catalog](https://github.com/eu-digital-identity-wallet/eudi-doc-standards-and-technical-specifications) ### OpenID + - [OIDC4VP 1.0](https://github.com/openid/OpenID4VP) - [OpenID4VC HAIP](https://openid.github.io/OpenID4VC-HAIP/openid4vc-high-assurance-interoperability-profile-wg-draft.html) - [OIDC4VCI](https://openid.github.io/OpenID4VCI/openid-4-verifiable-credential-issuance-1_0.html) ### Gaia-X + - [Gaia-X ICAM Credential Format (24.07)](https://docs.gaia-x.eu/technical-committee/identity-credential-access-management/24.07/credential_format/) - [Gaia-X Architecture — Credential Formats](https://gaia-x.gitlab.io/technical-committee/architecture-working-group/architecture-document/credential_formats_protocols/) ### Libraries + - [npm jose](https://www.npmjs.com/package/jose) — JavaScript JOSE - [joserfc](https://pypi.org/project/joserfc/) — Python JOSE - [@sd-jwt/sd-jwt-vc](https://www.npmjs.com/package/@sd-jwt/sd-jwt-vc) — JavaScript SD-JWT-VC (OpenWallet Foundation) diff --git a/docs/decisions/002-dual-runtime-architecture.md b/docs/decisions/002-dual-runtime-architecture.md index 73b7b3a..4012172 100644 --- a/docs/decisions/002-dual-runtime-architecture.md +++ b/docs/decisions/002-dual-runtime-architecture.md @@ -47,7 +47,7 @@ harbour-credentials/ │ ├── python/credentials/ # Python credentials module tests │ ├── typescript/harbour/ # TypeScript tests (vitest) │ └── interop/ # Cross-runtime interop tests -├── linkml/ # LinkML schemas (harbour.yaml, core.yaml, gaiax-domain.yaml) +├── linkml/ # LinkML schemas (harbour-core-credential.yaml, harbour-gx-credential.yaml) ├── artifacts/ # Generated OWL/SHACL/JSON-LD context └── docs/ ``` diff --git a/docs/guide/evidence.md b/docs/guide/evidence.md index 7233066..9796349 100644 --- a/docs/guide/evidence.md +++ b/docs/guide/evidence.md @@ -168,7 +168,7 @@ signed_vc = sign_vc_jose(credential, issuer_private_key) ## Schema Definition -Evidence types are defined in `linkml/harbour.yaml`: +Evidence types are defined in `linkml/harbour-core-credential.yaml`: ```yaml Evidence: diff --git a/examples/gaiax/legal-person-credential-bmw.json b/examples/gaiax/legal-person-credential-bmw.json new file mode 100644 index 0000000..54a37a3 --- /dev/null +++ b/examples/gaiax/legal-person-credential-bmw.json @@ -0,0 +1,63 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#", + "https://w3id.org/reachhaven/harbour/credentials/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:LegalPersonCredential" + ], + "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "validFrom": "2025-01-15T00:00:00Z", + "validUntil": "2030-01-15T00:00:00Z", + "credentialSubject": { + "id": "did:web:did.ascs.digital:participants:bmw", + "type": "harbour:LegalPerson", + "gxParticipant": { + "type": "gx:LegalPerson", + "schema:name": "Bayerische Motoren Werke Aktiengesellschaft", + "gx:registrationNumber": [ + { + "type": "gx:RegistrationNumber", + "gx:vatID": "DE129273398" + } + ], + "gx:legalAddress": { + "type": "gx:Address", + "gx:countryCode": "DE", + "gx:countryName": "Germany", + "vcard:street-address": "Petuelring 130", + "vcard:postal-code": "80809", + "vcard:locality": "München" + }, + "gx:headquartersAddress": { + "type": "gx:Address", + "gx:countryCode": "DE", + "gx:countryName": "Germany", + "vcard:street-address": "Petuelring 130", + "vcard:postal-code": "80809", + "vcard:locality": "München" + } + } + }, + "credentialStatus": [ + { + "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry#a1b2c3d4e5f67890", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ], + "evidence": [ + { + "type": "harbour:CredentialEvidence", + "verifiablePresentation": { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["VerifiablePresentation"], + "holder": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "nonce": "c7821a0b ISSUE_PAYLOAD 9e5f3a2d1b7c4e6f8a0d2c4b6e8f0a2c4d6e8b0f2a4c6d8e0b2a4c6d8e0f2a" + } + } + ] +} diff --git a/linkml/harbour.yaml b/linkml/harbour-core-credential.yaml similarity index 99% rename from linkml/harbour.yaml rename to linkml/harbour-core-credential.yaml index 6afe338..ba01067 100644 --- a/linkml/harbour.yaml +++ b/linkml/harbour-core-credential.yaml @@ -6,7 +6,7 @@ description: > credentialStatus), evidence types, revocation (CRSet), DID document structure, and trust anchor services. Domain-specific participant and credential types are defined in separate domain schemas - (e.g. gaiax-domain.yaml). + (e.g. harbour-gx-credential.yaml). prefixes: linkml: https://w3id.org/linkml/ diff --git a/linkml/gaiax-domain.yaml b/linkml/harbour-gx-credential.yaml similarity index 98% rename from linkml/gaiax-domain.yaml rename to linkml/harbour-gx-credential.yaml index 00d9042..b9ae248 100644 --- a/linkml/gaiax-domain.yaml +++ b/linkml/harbour-gx-credential.yaml @@ -1,5 +1,5 @@ id: https://w3id.org/reachhaven/harbour/gaiax-domain/v1 -name: gaiax-domain +name: harbour-gx-credential description: > Gaia-X domain layer for Harbour credentials. Defines participant types that wrap Gaia-X compliance data via @@ -19,7 +19,7 @@ default_range: string imports: - linkml:types - - ./harbour + - ./harbour-core-credential # ========================================== # Composition Slots diff --git a/tests/python/credentials/test_validation.py b/tests/python/credentials/test_validation.py index dabbb48..fce3add 100644 --- a/tests/python/credentials/test_validation.py +++ b/tests/python/credentials/test_validation.py @@ -5,8 +5,8 @@ 2. Context consistency: fixture property names match the generated contexts 3. SHACL conformance: credential structure conforms to generated SHACL shapes -Harbour base artifacts live in artifacts/harbour/. -Gaia-X domain artifacts live in artifacts/gaiax-domain/. +Harbour base artifacts live in artifacts/harbour-core-credential/. +Gaia-X domain artifacts live in artifacts/harbour-gx-credential/. """ import json @@ -21,14 +21,14 @@ EXAMPLES_DIR = _REPO_ROOT / "examples" # Harbour base artifacts -HARBOUR_ARTIFACTS_DIR = _REPO_ROOT / "artifacts" / "harbour" -HARBOUR_CONTEXT_PATH = HARBOUR_ARTIFACTS_DIR / "harbour.context.jsonld" -HARBOUR_SHACL_PATH = HARBOUR_ARTIFACTS_DIR / "harbour.shacl.ttl" +HARBOUR_ARTIFACTS_DIR = _REPO_ROOT / "artifacts" / "harbour-core-credential" +HARBOUR_CONTEXT_PATH = HARBOUR_ARTIFACTS_DIR / "harbour-core-credential.context.jsonld" +HARBOUR_SHACL_PATH = HARBOUR_ARTIFACTS_DIR / "harbour-core-credential.shacl.ttl" # Gaia-X domain artifacts -DOMAIN_ARTIFACTS_DIR = _REPO_ROOT / "artifacts" / "gaiax-domain" -DOMAIN_CONTEXT_PATH = DOMAIN_ARTIFACTS_DIR / "gaiax-domain.context.jsonld" -DOMAIN_SHACL_PATH = DOMAIN_ARTIFACTS_DIR / "gaiax-domain.shacl.ttl" +DOMAIN_ARTIFACTS_DIR = _REPO_ROOT / "artifacts" / "harbour-gx-credential" +DOMAIN_CONTEXT_PATH = DOMAIN_ARTIFACTS_DIR / "harbour-gx-credential.context.jsonld" +DOMAIN_SHACL_PATH = DOMAIN_ARTIFACTS_DIR / "harbour-gx-credential.shacl.ttl" def _load_json(path: Path) -> dict: @@ -180,18 +180,18 @@ def test_base_class_iris_are_prefixed(self): # --------------------------------------------------------------------------- -# 2b. Context consistency — gaiax-domain +# 2b. Context consistency — harbour-gx-credential # --------------------------------------------------------------------------- _skip_no_domain_artifacts = pytest.mark.skipif( not DOMAIN_CONTEXT_PATH.exists(), - reason="Generated gaiax-domain artifacts not found — run 'make generate'", + reason="Generated harbour-gx-credential artifacts not found — run 'make generate'", ) @_skip_no_domain_artifacts class TestDomainContextConsistency: - """Verify that generated gaiax-domain context covers domain types.""" + """Verify that generated harbour-gx-credential context covers domain types.""" def test_context_has_domain_classes(self): ctx = _load_json(DOMAIN_CONTEXT_PATH).get("@context", {}) @@ -202,7 +202,7 @@ def test_context_has_domain_classes(self): "NaturalPerson", ] for cls in domain_classes: - assert cls in ctx, f"Missing {cls} in gaiax-domain context" + assert cls in ctx, f"Missing {cls} in harbour-gx-credential context" def test_context_has_composition_slots(self): ctx = _load_json(DOMAIN_CONTEXT_PATH).get("@context", {}) @@ -288,16 +288,16 @@ def test_evidence_shapes_require_verifiable_presentation(self): # --------------------------------------------------------------------------- -# 3b. SHACL conformance — gaiax-domain shapes +# 3b. SHACL conformance — harbour-gx-credential shapes # --------------------------------------------------------------------------- @pytest.mark.skipif( not DOMAIN_SHACL_PATH.exists(), - reason="Generated gaiax-domain artifacts not found — run 'make generate'", + reason="Generated harbour-gx-credential artifacts not found — run 'make generate'", ) class TestDomainShaclShapes: - """Verify that SHACL shapes exist for gaiax-domain types.""" + """Verify that SHACL shapes exist for harbour-gx-credential types.""" def test_shacl_is_non_empty(self): content = DOMAIN_SHACL_PATH.read_text() From ce7309859c96f57d4f60d58637e6967bfe181993 Mon Sep 17 00:00:00 2001 From: jdsika Date: Mon, 2 Mar 2026 16:16:40 +0100 Subject: [PATCH 13/78] fix(makefile): resolve PYTHON path correctly in CI for validate-shacl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In CI, PYTHON is a bare 'python3' command. $(abspath python3) wrongly resolves to $CWD/python3 instead of using PATH. Introduce PYTHON_ABS that uses $(shell which) in CI and $(abspath) locally. Also update the required ontology check from harbour-core-credential to harbour-gx-credential — the auto-discovery loads the gx ontology (which transitively imports core) based on types found in the example data. Signed-off-by: jdsika --- Makefile | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 3bf6820..f6cbce2 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,15 @@ endif # Bootstrap interpreter used only to create the venv BOOTSTRAP_PYTHON := python3 +# Absolute path to Python (for use after cd into subdirectories). +# In CI, PYTHON is a bare command ('python3') so resolve via PATH; +# locally it is a relative venv path so abspath works. +ifdef CI + PYTHON_ABS := $(shell which $(PYTHON)) +else + PYTHON_ABS := $(abspath $(PYTHON)) +endif + # Tooling inside the selected virtual environment PIP := $(PYTHON) -m pip PRECOMMIT := $(PYTHON) -m pre_commit @@ -137,10 +146,10 @@ submodule-setup: elif [ -f "$(OMB_SUBMODULE_DIR)/Makefile" ]; then \ $(MAKE) --no-print-directory -C $(OMB_SUBMODULE_DIR) setup \ VENV="$(abspath $(VENV))" \ - PYTHON="$(abspath $(PYTHON))" \ - PIP="$(abspath $(PYTHON)) -m pip" \ - PRECOMMIT="$(abspath $(PYTHON)) -m pre_commit" \ - PYTEST="$(abspath $(PYTHON)) -m pytest"; \ + PYTHON="$(PYTHON_ABS)" \ + PIP="$(PYTHON_ABS) -m pip" \ + PRECOMMIT="$(PYTHON_ABS) -m pre_commit" \ + PYTEST="$(PYTHON_ABS) -m pytest"; \ echo "OK: ontology-management-base submodule setup complete"; \ else \ echo "WARNING: Skipping ontology-management-base submodule setup (not found)"; \ @@ -201,7 +210,7 @@ validate-shacl: @echo "Running SHACL data conformance check on examples..." @cd $(OMB_SUBMODULE_DIR) && \ tmp_output=$$(mktemp) && \ - $(abspath $(PYTHON)) -m src.tools.validators.validation_suite \ + $(PYTHON_ABS) -m src.tools.validators.validation_suite \ --run check-data-conformance \ --data-paths ../../examples/ ../../examples/gaiax/ ../../tests/validation-probe/ontology-loading-probe.json \ --artifacts ../../artifacts > $$tmp_output 2>&1 ; \ @@ -214,7 +223,7 @@ validate-shacl: for required in \ "imports/cs/cs.owl.ttl" \ "imports/cred/cred.owl.ttl" \ - "../../artifacts/harbour-core-credential/harbour-core-credential.owl.ttl" \ + "../../artifacts/harbour-gx-credential/harbour-gx-credential.owl.ttl" \ "artifacts/gx/gx.owl.ttl" ; do \ if ! grep -q "$$required" $$tmp_output ; then \ echo "ERROR: Required ontology not loaded by validation suite: $$required" >&2 ; \ From 89c475eb33778c32981180a90b37c1a0e658b4cb Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Fri, 6 Mar 2026 10:58:12 +0100 Subject: [PATCH 14/78] fix(examples): add BMW natural person credentials for Andreas and Max - Create gaiax/natural-person-credential-andreas.json (UUID b2c3d4e5) - Create gaiax/natural-person-credential-max.json (UUID c3d4e5f6) - Both use did:web:did.ascs.digital subject DIDs and BMW as memberOf - Resolves broken harbourCredential references from simpulseid layer Signed-off-by: Carlo van Driesten --- .../natural-person-credential-andreas.json | 67 +++++++++++++++++++ .../gaiax/natural-person-credential-max.json | 67 +++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 examples/gaiax/natural-person-credential-andreas.json create mode 100644 examples/gaiax/natural-person-credential-max.json diff --git a/examples/gaiax/natural-person-credential-andreas.json b/examples/gaiax/natural-person-credential-andreas.json new file mode 100644 index 0000000..9bc6a22 --- /dev/null +++ b/examples/gaiax/natural-person-credential-andreas.json @@ -0,0 +1,67 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#", + "https://w3id.org/reachhaven/harbour/credentials/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:NaturalPersonCredential" + ], + "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f23456789012", + "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "validFrom": "2025-01-15T00:00:00Z", + "validUntil": "2030-01-15T00:00:00Z", + "credentialSubject": { + "id": "did:web:did.ascs.digital:users:21c7c8bc-6860-490b-8ec7-219c89d93e2c", + "type": "harbour:NaturalPerson", + "name": "Andreas Admin", + "schema:givenName": "Andreas", + "schema:familyName": "Admin", + "schema:email": "andreas.admin@bmw.com", + "memberOf": "did:web:did.ascs.digital:participants:bmw", + "gxParticipant": { + "type": "gx:Participant", + "schema:name": "Andreas Admin" + } + }, + "credentialStatus": [ + { + "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry/b2c3d4e5f6a78901", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ], + "evidence": [ + { + "type": "harbour:CredentialEvidence", + "verifiablePresentation": { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["VerifiablePresentation"], + "holder": "did:web:did.ascs.digital:participants:bmw", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#", + "https://w3id.org/reachhaven/harbour/credentials/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:LegalPersonCredential" + ], + "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "credentialSubject": { + "id": "did:web:did.ascs.digital:participants:bmw", + "type": "harbour:LegalPerson", + "gxParticipant": { + "type": "gx:LegalPerson", + "schema:name": "Bayerische Motoren Werke Aktiengesellschaft" + } + } + } + ] + } + } + ] +} diff --git a/examples/gaiax/natural-person-credential-max.json b/examples/gaiax/natural-person-credential-max.json new file mode 100644 index 0000000..f9fc208 --- /dev/null +++ b/examples/gaiax/natural-person-credential-max.json @@ -0,0 +1,67 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#", + "https://w3id.org/reachhaven/harbour/credentials/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:NaturalPersonCredential" + ], + "id": "urn:uuid:c3d4e5f6-a7b8-9012-cdef-345678901234", + "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "validFrom": "2025-01-15T00:00:00Z", + "validUntil": "2030-01-15T00:00:00Z", + "credentialSubject": { + "id": "did:web:did.ascs.digital:users:44b982bb-ae61-4f6f-899f-a0982aaf367e", + "type": "harbour:NaturalPerson", + "name": "Max Mustermann", + "schema:givenName": "Max", + "schema:familyName": "Mustermann", + "schema:email": "max.mustermann@bmw.com", + "memberOf": "did:web:did.ascs.digital:participants:bmw", + "gxParticipant": { + "type": "gx:Participant", + "schema:name": "Max Mustermann" + } + }, + "credentialStatus": [ + { + "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry/c3d4e5f6a7b89012", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ], + "evidence": [ + { + "type": "harbour:CredentialEvidence", + "verifiablePresentation": { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["VerifiablePresentation"], + "holder": "did:web:did.ascs.digital:participants:bmw", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#", + "https://w3id.org/reachhaven/harbour/credentials/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:LegalPersonCredential" + ], + "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "credentialSubject": { + "id": "did:web:did.ascs.digital:participants:bmw", + "type": "harbour:LegalPerson", + "gxParticipant": { + "type": "gx:LegalPerson", + "schema:name": "Bayerische Motoren Werke Aktiengesellschaft" + } + } + } + ] + } + } + ] +} From c385b02511b7a9f877eae3b03f9c114ceaff0c49 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Fri, 6 Mar 2026 16:06:07 +0100 Subject: [PATCH 15/78] refactor(linkml): eliminate SHACL ghost properties and fix issuer nodeKind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove id from class slots: lists — identifier: true already maps to @id; listing in slots generated invalid sh:path harbour:id property shapes (7 classes: DIDDocument, TrustAnchorService, LinkedCredentialService, CRSetRevocationRegistryService, HarbourCredential, CRSetEntry, VerificationMethod) - Change Evidence class_uri from cred:Evidence to harbour:Evidence to match subclass namespace and produce correct sh:class constraint - Add HarbourShaclGenerator with cred:issuer nodeKind fix — W3C VC v2 context defines issuer as @type: @id (IRI), not sh:Literal - Replace gen-shacl/gen-owl/gen-jsonld-context CLI calls in Makefile with unified generate_artifacts.py script - Fix CRSet status ID URIs: #fragment to /path (did:webs uses path separators for service sub-resources, not fragments) Signed-off-by: Carlo van Driesten --- Makefile | 17 +--- examples/delegated-signing-receipt.json | 2 +- .../gaiax/legal-person-credential-bmw.json | 2 +- examples/gaiax/legal-person-credential.json | 2 +- examples/gaiax/natural-person-credential.json | 2 +- examples/legal-person-credential.json | 2 +- examples/natural-person-credential.json | 2 +- examples/trust-anchor-credential.json | 2 +- linkml/harbour-core-credential.yaml | 9 +-- src/python/harbour/generate_artifacts.py | 77 +++++++++++++++++++ 10 files changed, 86 insertions(+), 31 deletions(-) create mode 100644 src/python/harbour/generate_artifacts.py diff --git a/Makefile b/Makefile index f6cbce2..ccc5073 100644 --- a/Makefile +++ b/Makefile @@ -61,15 +61,6 @@ endef # LinkML schema files LINKML_SCHEMAS := $(wildcard linkml/*.yaml) DOMAINS := harbour-core-credential harbour-gx-credential -ifdef CI - GEN_OWL := gen-owl - GEN_SHACL := gen-shacl - GEN_JSONLD_CONTEXT := gen-jsonld-context -else - GEN_OWL := $(VENV)/bin/gen-owl - GEN_SHACL := $(VENV)/bin/gen-shacl - GEN_JSONLD_CONTEXT := $(VENV)/bin/gen-jsonld-context -endif # Default target help: @@ -187,13 +178,7 @@ endif generate: $(call check_dev_setup) @echo "Generating artifacts from LinkML schemas..." - @for domain in $(DOMAINS); do \ - echo " Processing $$domain..."; \ - mkdir -p artifacts/$$domain; \ - $(GEN_OWL) linkml/$$domain.yaml > artifacts/$$domain/$$domain.owl.ttl; \ - $(GEN_SHACL) linkml/$$domain.yaml > artifacts/$$domain/$$domain.shacl.ttl; \ - $(GEN_JSONLD_CONTEXT) linkml/$$domain.yaml > artifacts/$$domain/$$domain.context.jsonld; \ - done + @PYTHONPATH=src/python:$$PYTHONPATH $(PYTHON) src/python/harbour/generate_artifacts.py @echo "" @echo "OK: Artifacts generated in artifacts/" diff --git a/examples/delegated-signing-receipt.json b/examples/delegated-signing-receipt.json index 064f9a3..b67a904 100644 --- a/examples/delegated-signing-receipt.json +++ b/examples/delegated-signing-receipt.json @@ -18,7 +18,7 @@ }, "credentialStatus": [ { - "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry#f7e8d9c0b1a23456", + "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry/f7e8d9c0b1a23456", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } diff --git a/examples/gaiax/legal-person-credential-bmw.json b/examples/gaiax/legal-person-credential-bmw.json index 54a37a3..d976298 100644 --- a/examples/gaiax/legal-person-credential-bmw.json +++ b/examples/gaiax/legal-person-credential-bmw.json @@ -44,7 +44,7 @@ }, "credentialStatus": [ { - "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry#a1b2c3d4e5f67890", + "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry/a1b2c3d4e5f67890", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } diff --git a/examples/gaiax/legal-person-credential.json b/examples/gaiax/legal-person-credential.json index 7a0b64c..5f59e3d 100644 --- a/examples/gaiax/legal-person-credential.json +++ b/examples/gaiax/legal-person-credential.json @@ -38,7 +38,7 @@ }, "credentialStatus": [ { - "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry#a1b2c3d4e5f67890", + "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry/a1b2c3d4e5f67890", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } diff --git a/examples/gaiax/natural-person-credential.json b/examples/gaiax/natural-person-credential.json index 5db6904..85e89a1 100644 --- a/examples/gaiax/natural-person-credential.json +++ b/examples/gaiax/natural-person-credential.json @@ -26,7 +26,7 @@ }, "credentialStatus": [ { - "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry#b2c3d4e5f6a78901", + "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry/b2c3d4e5f6a78901", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } diff --git a/examples/legal-person-credential.json b/examples/legal-person-credential.json index e4b9a03..8642d1f 100644 --- a/examples/legal-person-credential.json +++ b/examples/legal-person-credential.json @@ -18,7 +18,7 @@ }, "credentialStatus": [ { - "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry#a1b2c3d4e5f67890", + "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry/a1b2c3d4e5f67890", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } diff --git a/examples/natural-person-credential.json b/examples/natural-person-credential.json index ac66c59..284e9ef 100644 --- a/examples/natural-person-credential.json +++ b/examples/natural-person-credential.json @@ -22,7 +22,7 @@ }, "credentialStatus": [ { - "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry#b2c3d4e5f6a78901", + "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry/b2c3d4e5f6a78901", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } diff --git a/examples/trust-anchor-credential.json b/examples/trust-anchor-credential.json index d90984d..4a50f89 100644 --- a/examples/trust-anchor-credential.json +++ b/examples/trust-anchor-credential.json @@ -17,7 +17,7 @@ }, "credentialStatus": [ { - "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo:services:revocation-registry#c4d5e6f7a8b90123", + "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo:services:revocation-registry/c4d5e6f7a8b90123", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } diff --git a/linkml/harbour-core-credential.yaml b/linkml/harbour-core-credential.yaml index ba01067..3394175 100644 --- a/linkml/harbour-core-credential.yaml +++ b/linkml/harbour-core-credential.yaml @@ -202,7 +202,6 @@ classes: DIDDocument: class_uri: https://www.w3.org/ns/did#DIDDocument slots: - - id - controller attributes: # Spec: DID-Core §5.4 — service is OPTIONAL, each entry MUST have id, type, @@ -235,7 +234,6 @@ classes: TrustAnchorService: class_uri: harbour:TrustAnchorService slots: - - id - serviceEndpoint slot_usage: serviceEndpoint: @@ -248,7 +246,6 @@ classes: LinkedCredentialService: class_uri: harbour:LinkedCredentialService slots: - - id - serviceEndpoint slot_usage: serviceEndpoint: @@ -282,7 +279,6 @@ classes: CRSetRevocationRegistryService: class_uri: harbour:CRSetRevocationRegistryService slots: - - id - serviceEndpoint slot_usage: serviceEndpoint: @@ -317,7 +313,6 @@ classes: and credentialStatus with at least one CRSetEntry for revocation support. class_uri: harbour:HarbourCredential slots: - - id - issuer - validFrom - validUntil @@ -342,7 +337,7 @@ classes: # No specific evidence subtypes are defined by the base spec. Evidence: abstract: true - class_uri: cred:Evidence + class_uri: harbour:Evidence # Harbour-specific evidence type for authorization proof during issuance. # Spec: VCDM2 §5.6 — evidence can contain any claims (extensible). @@ -402,7 +397,6 @@ classes: CRSetEntry: class_uri: harbour:CRSetEntry slots: - - id - statusPurpose # ========================================== @@ -416,7 +410,6 @@ classes: VerificationMethod: class_uri: https://www.w3.org/ns/did#VerificationMethod slots: - - id - controller attributes: blockchainAccountId: diff --git a/src/python/harbour/generate_artifacts.py b/src/python/harbour/generate_artifacts.py new file mode 100644 index 0000000..821dd4a --- /dev/null +++ b/src/python/harbour/generate_artifacts.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Generate downstream artifacts (OWL ontology, SHACL shapes, JSON-LD context) +from Harbour LinkML schemas. + +The custom HarbourShaclGenerator fixes the ``cred:issuer`` property shape: +LinkML maps ``range: string`` to ``sh:nodeKind sh:Literal``, but the W3C VC v2 +context defines ``issuer`` with ``@type: @id``, so the RDF value is an IRI. +The generator patches the property shape to ``sh:nodeKind sh:IRIOrLiteral`` +(accepting both IRIs from JSON-LD and literal strings from plain JSON). +""" + +from pathlib import Path + +from linkml.generators.jsonldcontextgen import ContextGenerator +from linkml.generators.owlgen import OwlSchemaGenerator +from linkml.generators.shaclgen import ShaclGenerator as _BaseShaclGenerator +from rdflib import Namespace + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent +LINKML_DIR = REPO_ROOT / "linkml" +ARTIFACTS_DIR = REPO_ROOT / "artifacts" + +DOMAINS = ["harbour-core-credential", "harbour-gx-credential"] + +SH = Namespace("http://www.w3.org/ns/shacl#") +XSD = Namespace("http://www.w3.org/2001/XMLSchema#") +CRED = Namespace("https://www.w3.org/2018/credentials#") + + +class HarbourShaclGenerator(_BaseShaclGenerator): + """SHACL generator that corrects IRI-valued property shapes. + + ``cred:issuer`` is defined as ``@type: @id`` in the W3C VC v2 context, + meaning JSON-LD processors expand issuer values to IRIs. LinkML has no + native IRI range type, so we patch the generated graph directly. + """ + + def as_graph(self): + g = super().as_graph() + # Find property shapes targeting cred:issuer and fix nodeKind + for ps in g.subjects(SH.path, CRED.issuer): + g.remove((ps, SH.nodeKind, SH.Literal)) + g.add((ps, SH.nodeKind, SH.IRIOrLiteral)) + # Remove sh:datatype — IRI nodes don't carry a datatype + for dt in list(g.objects(ps, SH.datatype)): + g.remove((ps, SH.datatype, dt)) + return g + + +def main() -> None: + for domain in DOMAINS: + schema = str(LINKML_DIR / f"{domain}.yaml") + out_dir = ARTIFACTS_DIR / domain + out_dir.mkdir(parents=True, exist_ok=True) + + print(f" Processing {domain}...") + + owl_gen = OwlSchemaGenerator(schema) + (out_dir / f"{domain}.owl.ttl").write_text( + owl_gen.serialize(), encoding="utf-8" + ) + + shacl_gen = HarbourShaclGenerator(schema) + (out_dir / f"{domain}.shacl.ttl").write_text( + shacl_gen.serialize(), encoding="utf-8" + ) + + ctx_gen = ContextGenerator(schema) + (out_dir / f"{domain}.context.jsonld").write_text( + ctx_gen.serialize(), encoding="utf-8" + ) + + print(f"\nDone: {ARTIFACTS_DIR}/") + + +if __name__ == "__main__": + main() From a08d3c28fff6b2a496e8532757e3d9c44120eb11 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Mon, 9 Mar 2026 10:25:54 +0100 Subject: [PATCH 16/78] fix(shacl): add linkml issue refs and strip sh:class linkml:Any constraints - Add GitHub issue references (linkml/linkml#2914) to HarbourShaclGenerator - Strip sh:class linkml:Any from generated SHACL (meta-type not in instance data) - Add Gaia-X 25.11 compliance fields to harbour-gx-credential schema - Add X.509 revocation checking stub Signed-off-by: Carlo van Driesten --- linkml/harbour-gx-credential.yaml | 18 ++++++++++++++---- src/python/harbour/generate_artifacts.py | 12 ++++++++++-- src/python/harbour/x509.py | 8 ++++++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/linkml/harbour-gx-credential.yaml b/linkml/harbour-gx-credential.yaml index b9ae248..237c5d0 100644 --- a/linkml/harbour-gx-credential.yaml +++ b/linkml/harbour-gx-credential.yaml @@ -29,8 +29,9 @@ imports: # Harbour cannot add properties to gx nodes without violating sh:closed. # Solution: composition — harbour outer node owns harbour properties; # nested gx blank node carries only gx properties. -# Spec: Gaia-X Trust Framework 22.10 — gx:LegalPerson, gx:Participant, -# Gaia-X shapes requiring specific properties. +# Spec: Gaia-X Trust Framework 25.11 (GX-ICAM-25.11, GX-TF-ARCH) — +# gx:LegalPerson, gx:Participant; Gaia-X shapes requiring specific +# properties (registrationNumber, headquartersAddress, legalAddress). slots: gxParticipant: @@ -62,6 +63,11 @@ classes: (name). When Gaia-X compliance is needed, gxParticipant carries the gx:LegalPerson blank node with compliance data. class_uri: harbour:LegalPersonCredential + annotations: + # Spec: SD-JWT-VC-15 §3.2.2.1 — vct MUST be a case-sensitive + # StringOrURI identifying the credential type. + # Spec: SD-JWT-VC-TYPES — vct URI SHOULD be stable and dereferenceable. + vct: "https://w3id.org/reachhaven/harbour/credentials/v1/LegalPersonCredential" slot_usage: validFrom: required: true @@ -76,6 +82,8 @@ classes: (name, givenName, familyName, email, memberOf). When Gaia-X compliance is needed, gxParticipant carries the gx:Participant blank node. class_uri: harbour:NaturalPersonCredential + annotations: + vct: "https://w3id.org/reachhaven/harbour/credentials/v1/NaturalPersonCredential" slot_usage: validFrom: required: true @@ -88,8 +96,10 @@ classes: # Harbour wraps Gaia-X participant types via composition. # Gaia-X data lives in nested blank nodes (gxParticipant / # gxParticipant) to keep gx closed shapes intact. - # Spec: Gaia-X TF — gx:LegalPerson requires gx:registrationNumber (object), - # gx:headquartersAddress, gx:legalAddress. gx:Participant is the base type. + # Spec: Gaia-X Trust Framework 25.11 (GX-TF-ARCH) — + # gx:LegalPerson requires gx:registrationNumber (object), + # gx:headquartersAddress, gx:legalAddress. + # gx:Participant is the base type. LegalPerson: description: > diff --git a/src/python/harbour/generate_artifacts.py b/src/python/harbour/generate_artifacts.py index 821dd4a..50eeedf 100644 --- a/src/python/harbour/generate_artifacts.py +++ b/src/python/harbour/generate_artifacts.py @@ -25,6 +25,7 @@ SH = Namespace("http://www.w3.org/ns/shacl#") XSD = Namespace("http://www.w3.org/2001/XMLSchema#") CRED = Namespace("https://www.w3.org/2018/credentials#") +LINKML = Namespace("https://w3id.org/linkml/") class HarbourShaclGenerator(_BaseShaclGenerator): @@ -33,17 +34,24 @@ class HarbourShaclGenerator(_BaseShaclGenerator): ``cred:issuer`` is defined as ``@type: @id`` in the W3C VC v2 context, meaning JSON-LD processors expand issuer values to IRIs. LinkML has no native IRI range type, so we patch the generated graph directly. + + Also removes ``sh:class linkml:Any`` constraints: LinkML emits these for + ``range: Any`` slots, but ``linkml:Any`` is a meta-schema type never + asserted as ``rdf:type`` on instance data. + See https://github.com/linkml/linkml/issues/2914 """ def as_graph(self): g = super().as_graph() - # Find property shapes targeting cred:issuer and fix nodeKind + # Fix cred:issuer nodeKind (IRI, not Literal) for ps in g.subjects(SH.path, CRED.issuer): g.remove((ps, SH.nodeKind, SH.Literal)) g.add((ps, SH.nodeKind, SH.IRIOrLiteral)) - # Remove sh:datatype — IRI nodes don't carry a datatype for dt in list(g.objects(ps, SH.datatype)): g.remove((ps, SH.datatype, dt)) + # Remove sh:class linkml:Any — meta-type not present in instance data + for s, p, o in list(g.triples((None, SH["class"], LINKML.Any))): + g.remove((s, p, o)) return g diff --git a/src/python/harbour/x509.py b/src/python/harbour/x509.py index 35abf40..d5eaaaa 100644 --- a/src/python/harbour/x509.py +++ b/src/python/harbour/x509.py @@ -1,5 +1,13 @@ """X.509 certificate chain support for EUDI-compliant VC signing. +This module validates X.509 chain structure and signatures only. It does +NOT perform certificate revocation checking (CRL/OCSP) because Harbour +credentials use CRSet (harbour:CRSetEntry) for credential-level revocation, +not X.509 certificate revocation. X.509 chains provide issuer identity +binding; CRSet provides credential status. + +See: RFC 5280 (X.509 PKI), CRSet paper (https://arxiv.org/abs/2501.17089). + CLI Usage: python -m harbour.x509 --help python -m harbour.x509 generate --key key.jwk --subject "CN=Test" --output cert.pem From 88d0002fd9068b33e1cb96b5ee08a8d4de59574c Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Mon, 9 Mar 2026 18:09:05 +0100 Subject: [PATCH 17/78] feat: migrate DID method from did:web to did:ethr - Replace did:web with did:ethr using ERC-1056 on Base Sepolia (0x14a34) - Add did:ethr DID document examples for all ecosystem entities - Add ADR-005 documenting did:ethr migration rationale - Add did:ethr method specification reference - Update delegation challenge encoding for did:ethr format - Update DID method evaluation with did:ethr comparison - Update all examples and test fixtures to did:ethr identifiers - Remove deprecated did:web examples and references Signed-off-by: Carlo van Driesten --- README.md | 14 +- docs/architecture.md | 2 +- docs/decisions/001-vc-securing-mechanism.md | 10 +- docs/decisions/004-key-management.md | 4 +- docs/decisions/005-did-ethr-migration.md | 78 +++ docs/guide/delegated-signing.md | 46 +- docs/guide/evidence.md | 12 +- docs/specs/delegation-challenge-encoding.md | 30 +- docs/specs/did-method-evaluation.md | 400 ++++-------- docs/specs/references/README.md | 14 +- docs/specs/references/did-ethr-method-spec.md | 591 ++++++++++++++++++ examples/README.md | 74 +-- examples/delegated-signing-receipt.json | 16 +- examples/did-ethr/README.md | 42 ++ .../did-ethr/harbour-signing-service.did.json | 52 ++ .../did-ethr/harbour-trust-anchor.did.json | 42 ++ ...6d7ea-27ef-416f-abf8-9cb634884e66.did.json | 35 ++ ...e8400-e29b-41d4-a716-446655440000.did.json | 35 ++ examples/did-webs/README.md | 63 -- .../did-webs/harbour-signing-service.did.json | 42 -- .../did-webs/harbour-trust-anchor.did.json | 34 - ...6d7ea-27ef-416f-abf8-9cb634884e66.did.json | 27 - ...e8400-e29b-41d4-a716-446655440000.did.json | 27 - .../gaiax/legal-person-credential-bmw.json | 8 +- examples/gaiax/legal-person-credential.json | 12 +- .../natural-person-credential-andreas.json | 14 +- .../gaiax/natural-person-credential-max.json | 14 +- examples/gaiax/natural-person-credential.json | 14 +- examples/legal-person-credential.json | 12 +- examples/natural-person-credential.json | 14 +- examples/trust-anchor-credential.json | 6 +- linkml/harbour-core-credential.yaml | 2 +- src/python/harbour/generate_artifacts.py | 16 +- src/python/harbour/kb_jwt.py | 4 +- src/python/harbour/sd_jwt_vp.py | 2 +- tests/fixtures/canonicalization-vectors.json | 40 +- tests/fixtures/sample-vc.json | 4 +- tests/interop/test_cross_runtime.py | 20 +- .../python/credentials/test_claim_mapping.py | 10 +- .../python/credentials/test_example_signer.py | 10 +- .../python/credentials/test_sign_examples.py | 4 +- tests/python/harbour/test_kb_jwt.py | 2 +- tests/python/harbour/test_sd_jwt.py | 7 +- tests/python/harbour/test_sd_jwt_vp.py | 26 +- tests/python/harbour/test_sign.py | 6 +- tests/python/harbour/test_tamper.py | 6 +- tests/python/harbour/test_verify.py | 16 +- tests/python/harbour/test_x509.py | 13 +- tests/typescript/harbour/kb-jwt.test.ts | 8 +- tests/typescript/harbour/sd-jwt-vp.test.ts | 16 +- tests/typescript/harbour/sd-jwt.test.ts | 2 +- tests/typescript/harbour/sign.test.ts | 4 +- tests/typescript/harbour/tamper.test.ts | 4 +- tests/typescript/harbour/verify.test.ts | 8 +- 54 files changed, 1288 insertions(+), 726 deletions(-) create mode 100644 docs/decisions/005-did-ethr-migration.md create mode 100644 docs/specs/references/did-ethr-method-spec.md create mode 100644 examples/did-ethr/README.md create mode 100644 examples/did-ethr/harbour-signing-service.did.json create mode 100644 examples/did-ethr/harbour-trust-anchor.did.json create mode 100644 examples/did-ethr/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json create mode 100644 examples/did-ethr/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json delete mode 100644 examples/did-webs/README.md delete mode 100644 examples/did-webs/harbour-signing-service.did.json delete mode 100644 examples/did-webs/harbour-trust-anchor.did.json delete mode 100644 examples/did-webs/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json delete mode 100644 examples/did-webs/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json diff --git a/README.md b/README.md index ad347ec..631a056 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ JOSE signing and verification library for W3C Verifiable Credentials, supporting - **ES256 (P-256)**: EUDI HAIP compliant algorithm - **EdDSA (Ed25519)**: Supported (deprecated per RFC 9864, use ES256 for production) - **X.509 Support**: Certificate chains via `x5c` header -- **DID Support**: `did:key` key identifiers plus `did:web` / `did:webs` subject identifiers (resolution handled by integrators) +- **DID Support**: `did:key` key identifiers plus `did:ethr` subject identifiers (resolution handled by integrators) - **Selective Disclosure**: Native SD-JWT-VC with disclosable claims - **Key Binding**: KB-JWT for holder binding in presentations - **Harbour Credential Types**: Base credential framework with composition slots for Gaia-X compliance @@ -64,7 +64,7 @@ private_key, public_key = generate_p256_keypair() vc = { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiableCredential"], - "issuer": "did:web:example.com", + "issuer": "did:ethr:0x14a34:0x4ff70ba2fe8c4724a11da529381cbc391e5d8423", "credentialSubject": {"id": "did:example:holder", "name": "Alice"} } jwt = sign_vc_jose(vc, private_key) @@ -89,7 +89,7 @@ const { privateKey, publicKey } = await generateP256Keypair(); const vc = { "@context": ["https://www.w3.org/ns/credentials/v2"], type: ["VerifiableCredential"], - issuer: "did:web:example.com", + issuer: "did:ethr:0x14a34:0x4ff70ba2fe8c4724a11da529381cbc391e5d8423", credentialSubject: { id: "did:example:holder", name: "Alice" }, }; const jwt = await signVcJose(vc, privateKey); @@ -124,10 +124,10 @@ Base skeleton examples live in `examples/` (no Gaia-X data). Gaia-X domain exten "https://w3id.org/reachhaven/harbour/credentials/v1/" ], "type": ["VerifiableCredential", "harbour:LegalPersonCredential"], - "issuer": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "validFrom": "2024-01-15T00:00:00Z", "credentialSubject": { - "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", "type": "harbour:LegalPerson", "name": "Example Corporation GmbH", "gxParticipant": { @@ -146,7 +146,7 @@ Base skeleton examples live in `examples/` (no Gaia-X data). Gaia-X domain exten }, "credentialStatus": [ { - "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo:services:revocation-registry#abc123", + "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3:services:revocation-registry#abc123", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -226,7 +226,7 @@ examples/ ├── legal-person-credential.json # Harbour skeleton credentials ├── natural-person-credential.json # (canonical unsigned JSON-LD) ├── gaiax/ # Gaia-X domain extensions -└── did-webs/ # Example did:webs DID documents used by examples +└── did-ethr/ # Example did:ethr DID documents used by examples tests/ ├── fixtures/ # Shared test fixtures diff --git a/docs/architecture.md b/docs/architecture.md index 3c13b22..838689f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -34,7 +34,7 @@ harbour-credentials/ |--------|--------| | Proof format | SD-JWT-VC + VC-JOSE-COSE | | Algorithm | ES256 (P-256) primary, EdDSA (Ed25519) supported | -| Key resolution | X.509 (x5c) + did:webs + did:key | +| Key resolution | X.509 (x5c) + did:ethr + did:key | | Selective disclosure | Native (SD-JWT-VC) | | Canonicalization | None needed (JWT/SD-JWT) | | Runtimes | Python + TypeScript | diff --git a/docs/decisions/001-vc-securing-mechanism.md b/docs/decisions/001-vc-securing-mechanism.md index d2542e0..5684ab7 100644 --- a/docs/decisions/001-vc-securing-mechanism.md +++ b/docs/decisions/001-vc-securing-mechanism.md @@ -101,7 +101,7 @@ HAIP requires: > "The public key used to validate the signature MUST be included in the x5c JOSE header parameter" -Gaia-X uses DIDs (primarily `did:web`/`did:webs`) plus X.509 via GXDCH. We need to support **both** `x5c` (for EUDI) and DID resolution (for Gaia-X). Harbour uses `did:webs` for all identities. +Gaia-X uses DIDs (primarily `did:ethr`) plus X.509 via GXDCH. We need to support **both** `x5c` (for EUDI) and DID resolution (for Gaia-X). Harbour uses `did:ethr` for all identities. ### 3. SD-JWT-VC ≠ W3C VC Data Model @@ -137,7 +137,7 @@ Support **two complementary formats**, serving different purposes: | -------------------- | --------------------------------------------------- | | Format | SD-JWT-VC (compact serialization) | | Algorithm | **ES256** (ECDSA P-256) — HAIP mandatory minimum | -| Key resolution | X.509 via `x5c` header (EUDI) + `did:webs` (Gaia-X) | +| Key resolution | X.509 via `x5c` header (EUDI) + `did:ethr` (Gaia-X) | | Selective disclosure | Native SD-JWT | | Holder binding | `cnf` claim with proof-of-possession | | Status | `status_list` (Token Status List) | @@ -153,7 +153,7 @@ Support **two complementary formats**, serving different purposes: | Algorithm | **ES256** (consistent with SD-JWT-VC) | | JWT header | `{"alg": "ES256", "typ": "vc+jwt"}` | | Payload | Full W3C VCDM 2.0 JSON-LD | -| Key resolution | `did:webs` (Gaia-X) + `x5c` (EUDI alignment) | +| Key resolution | `did:ethr` (Gaia-X) + `x5c` (EUDI alignment) | | JS library | npm `jose` | | Python library | `joserfc` | @@ -163,7 +163,7 @@ Support **two complementary formats**, serving different purposes: | ------------ | ----------------- | ------------------------------------- | | Algorithm | Ed25519 (EdDSA) | **P-256 (ES256)** | | Key format | JWK OKP/Ed25519 | **JWK EC/P-256** | -| DID method | `did:key:z6Mk...` | `did:key:zDn...` (P-256) + `did:webs` | +| DID method | `did:key:z6Mk...` | `did:key:zDn...` (P-256) + `did:ethr` | | Certificates | None | X.509 chains via `x5c` | Ed25519 is also supported for testing, but **ES256 MUST be the default** for EUDI compliance. @@ -201,7 +201,7 @@ A credential can exist in multiple formats simultaneously. The mapping from SHAC ### Positive - EUDI wallet compatible (SD-JWT-VC + ES256 + x5c) -- Gaia-X compatible (VC-JWT + did:webs) +- Gaia-X compatible (VC-JWT + did:ethr) - Selective disclosure for privacy-sensitive fields - Both Python and JS implementations exist for SD-JWT-VC - Future-proof (SD-JWT-VC is the regulatory direction) diff --git a/docs/decisions/004-key-management.md b/docs/decisions/004-key-management.md index a4f9758..403291a 100644 --- a/docs/decisions/004-key-management.md +++ b/docs/decisions/004-key-management.md @@ -68,7 +68,7 @@ Three mechanisms, serving different ecosystems: | Method | Ecosystem | JOSE Header | Example | |--------|-----------|-------------|---------| | **X.509 chain** | EUDI | `x5c` | Certificate chain in JWT header | -| **did:webs** | Gaia-X | `kid` | `did:webs:participants.harbour.reachhaven.com:legal-persons::#key-1` | +| **did:ethr** | Gaia-X | `kid` | `did:ethr:0x14a34:
#delegate-1` | | **did:key** | Testing | `kid` | `did:key:zDn...#zDn...` | **X.509 (EUDI mandatory):** @@ -77,7 +77,7 @@ Three mechanisms, serving different ecosystems: - Trust anchor certificate excluded from chain - No self-signed end-entity certificates -**did:webs (Gaia-X):** +**did:ethr (Gaia-X):** - Resolves to DID Document at well-known URL with KERI key history - DID Document contains JWK public key(s) - Used for all Harbour identities (infrastructure, organizations, users) diff --git a/docs/decisions/005-did-ethr-migration.md b/docs/decisions/005-did-ethr-migration.md new file mode 100644 index 0000000..f7749c2 --- /dev/null +++ b/docs/decisions/005-did-ethr-migration.md @@ -0,0 +1,78 @@ +# ADR-005: Migration from did:web / did:webs to did:ethr + +## Status + +**Accepted** + +## Context + +Harbour previously supported two DID methods: + +- **did:web** — W3C CCG specification; DID documents hosted at well-known HTTPS URLs +- **did:webs** — ToIP/KERI extension; adds key event logs for cryptographic key history + +Both methods rely on web server infrastructure for DID document publication and discovery. +This creates dependencies on DNS, TLS certificate authorities, and hosting availability that +conflict with the project's goal of decentralised, self-sovereign identity. + +The ENVITED-X ecosystem requires: +1. Decentralised identity anchoring without web server dependencies +2. Verifiable key rotation history +3. P-256 key support (for EUDI/HAIP compliance) +4. Low-cost operations for credential issuance at scale + +## Decision + +Replace `did:web` and `did:webs` with **`did:ethr`** (ERC-1056 / EthereumDIDRegistry) +deployed on **Base** (Coinbase L2 rollup). + +### Key design choices + +| Aspect | Decision | +|--------|----------| +| **Blockchain** | Base (L2 rollup on Ethereum) | +| **Chain ID** | Testnet: 84532 (`0x14a34`), Mainnet: 8453 (`0x2105`) | +| **Contract** | ERC-1056 EthereumDIDRegistry (standard or custom with P-256 support) | +| **P-256 keys** | Registered as on-chain attributes via `setAttribute()` | +| **Controller** | Smart contract manages identity ownership | +| **DID format** | `did:ethr::` | + +### DID document structure + +The EthereumDIDRegistry resolves DID documents from on-chain events: + +- `DIDOwnerChanged` → `controller` field +- `DIDDelegateChanged` → `verificationMethod` entries (delegates) +- `DIDAttributeChanged` → `verificationMethod` entries (attributes like P-256 keys) + +## Consequences + +### Positive + +- **No web server dependency** — DID resolution reads blockchain state +- **Immutable audit trail** — All identity changes recorded on-chain +- **True decentralisation** — No DNS/TLS trust assumptions +- **Low cost** — Base L2 gas fees are minimal +- **Broad tooling** — `ethr-did-resolver` (JS), Python resolver libraries available +- **P-256 compatible** — Keys registered as typed attributes + +### Negative + +- **Gas costs** — Each identity operation requires a transaction (mitigated by L2 pricing) +- **Key material change** — Ethereum addresses derived from key material (secp256k1 native, P-256 via attributes) +- **Migration effort** — All examples, tests, and documentation require updates +- **KERI features lost** — Key event logs, witness network, pre-rotation not available (acceptable tradeoff) + +### Neutral + +- **did:key** remains supported for ephemeral/testing identifiers +- **X.509 (x5c)** remains supported for EUDI alignment +- **Archived specs** — did-web-method.txt and did-webs-spec.md retained in docs/specs/references/ for historical reference + +## References + +- [ERC-1056: Ethereum Lightweight Identity](https://eips.ethereum.org/EIPS/eip-1056) +- [did:ethr Method Specification](https://github.com/decentralized-identity/ethr-did-resolver/blob/master/doc/did-method-spec.md) +- [Base Documentation](https://docs.base.org/) +- [ADR-001: VC Securing Mechanism](001-vc-securing-mechanism.md) +- [ADR-004: Key Management](004-key-management.md) diff --git a/docs/guide/delegated-signing.md b/docs/guide/delegated-signing.md index 4a276ed..140d98c 100644 --- a/docs/guide/delegated-signing.md +++ b/docs/guide/delegated-signing.md @@ -62,30 +62,30 @@ The user needs a Harbour credential (e.g., `NaturalPersonCredential`) issued as ```json { "type": ["VerifiableCredential", "harbour:NaturalPersonCredential"], - "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "credentialSubject": { - "id": "did:webs:users.altme.example:natural-persons:550e8400-...:EKYGGh-...", + "id": "did:ethr:0x14a34:0x26e4...16c9", "type": "harbour:NaturalPerson", "name": "Alice Smith", // ← Disclosable (PII) "email": "alice.smith@example.com", // ← Disclosable (PII) - "memberOf": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-...:ENro7uf0eP..." + "memberOf": "did:ethr:0x14a34:0xf7ef...dab" } } ``` ### 2. DID Document -The user's `did:webs` DID document must contain a verification method with their P-256 public key (the same key as in their `did:jwk` wallet): +The user's `did:ethr` DID document must contain a verification method with their P-256 public key (the same key as in their `did:jwk` wallet): ```json { "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/jwk/v1"], - "id": "did:webs:users.altme.example:natural-persons:550e8400-...:EKYGGh-...", - "controller": "did:webs:users.altme.example:natural-persons:550e8400-...:EKYGGh-...", + "id": "did:ethr:0x14a34:0x26e4...16c9", + "controller": "did:ethr:0x14a34:0x26e4...16c9", "verificationMethod": [{ - "id": "did:webs:users.altme.example:natural-persons:550e8400-...:EKYGGh-...#key-1", + "id": "did:ethr:0x14a34:0x26e4...16c9#key-1", "type": "JsonWebKey2020", - "controller": "did:webs:users.altme.example:natural-persons:550e8400-...:EKYGGh-...", + "controller": "did:ethr:0x14a34:0x26e4...16c9", "publicKeyJwk": { "kty": "EC", "crv": "P-256", @@ -98,15 +98,15 @@ The user's `did:webs` DID document must contain a verification method with their } ``` -See [`examples/did-webs/`](../../examples/did-webs/) for complete DID documents. +See [`examples/did-ethr/`](../../examples/did-ethr/) for complete DID documents. -### Repository Boundary (did:web / did:webs) +### Repository Boundary (did:ethr) This repository verifies signatures and hash bindings, but it does **not** host or publish DID documents. -- Integrators must publish DID documents at the correct HTTPS location for the chosen method (`did:web` or `did:webs`). +- Integrators must publish DID documents at the correct HTTPS location for the chosen method (`did:ethr` or `did:ethr`). - Integrators must run DID resolution and pass the resolved holder key into `verify_sd_jwt_vp(...)`. -- Repository examples now use `did:webs` identifiers for person subjects. See `examples/did-webs/` for static example DID documents used by `examples/*.json`. +- Repository examples now use `did:ethr` identifiers for person subjects. See `examples/did-ethr/` for static example DID documents used by `examples/*.json`. - Naming policy in examples: - All identifiers use UUID-based path segments (no real names or organization names in DID paths). @@ -114,7 +114,7 @@ Current integration hooks and TODOs: - `issue_sd_jwt_vp(..., holder_did=...)` allows the wallet DID to be embedded in the consent VP. - `verify_sd_jwt_vp(..., holder_public_key=...)` accepts the DID-resolved public key from your resolver stack. -- TODO: Add optional resolver callback adapters for `did:web`/`did:webs` so verification can resolve keys in-process. +- TODO: Add optional resolver callback adapters for `did:ethr` so verification can resolve keys in-process. ## OID4VP Transaction Data @@ -131,7 +131,7 @@ The signing service creates an OID4VP-aligned transaction data object (see [Dele "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", - "marketplace": "did:web:dataspace.envited.io" + "marketplace": "did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c" } } ``` @@ -171,7 +171,7 @@ evidence = [{ "currency": "ENVITED" } }, - "delegatedTo": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ" + "delegatedTo": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697" }] # Create VP with selective disclosure (redact PII) @@ -181,7 +181,7 @@ sd_jwt_vp = issue_sd_jwt_vp( disclosures=["memberOf"], # Only disclose non-PII claims evidence=evidence, nonce="da9b1009", - audience="did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ" + audience="did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697" ) ``` @@ -205,10 +205,10 @@ const sdJwtVp = await issueSdJwtVp(sdJwtVc, holderPrivateKey, { currency: 'ENVITED' } }, - delegatedTo: 'did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ' + delegatedTo: 'did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697' }], nonce: 'da9b1009', - audience: 'did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ' + audience: 'did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697' }); ``` @@ -226,7 +226,7 @@ result = verify_sd_jwt_vp( issuer_public_key, # From credential issuer's DID holder_public_key, # From user's DID document expected_nonce="da9b1009", - expected_audience="did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ" + expected_audience="did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697" ) # Check transaction data matches original request @@ -247,11 +247,11 @@ After executing the transaction, the signing service issues a **receipt credenti ```json { "type": ["VerifiableCredential", "harbour:DelegatedSigningReceipt"], - "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "evidence": [{ "type": "harbour:DelegatedSignatureEvidence", "verifiablePresentation": "", - "delegatedTo": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "delegatedTo": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "transaction_data": { "..." } }], "credentialStatus": [{ @@ -295,7 +295,7 @@ The `audience` field ensures the VP was created for a specific verifier: verify_sd_jwt_vp( vp, ..., - expected_audience="did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ" + expected_audience="did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697" ) ``` @@ -317,7 +317,7 @@ Verify the VP signature matches the public key in the user's DID document: ```python # Resolve DID document (integrator-provided resolver) -did_doc = resolve_did("did:webs:users.altme.example:natural-persons:550e8400-...:EKYGGh-...") +did_doc = resolve_did("did:ethr:0x14a34:0x26e4...16c9") # Extract public key public_key = did_doc["verificationMethod"][0]["publicKeyJwk"] diff --git a/docs/guide/evidence.md b/docs/guide/evidence.md index 9796349..b32ba57 100644 --- a/docs/guide/evidence.md +++ b/docs/guide/evidence.md @@ -29,14 +29,14 @@ The Harbour Signing Service is the **sole issuer** of all credentials. Evidence "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "holder": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "verifiableCredential": [ { "@context": ["https://www.w3.org/ns/credentials/v2", "..."], "type": ["VerifiableCredential", "harbour:LegalPersonCredential"], - "issuer": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "credentialSubject": { - "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "type": "harbour:LegalPerson", "name": "ReachHaven GmbH" } @@ -58,7 +58,7 @@ Evidence on a **receipt credential** (SD-JWT-VC) that a signing service executed { "type": "harbour:DelegatedSignatureEvidence", "verifiablePresentation": "", - "delegatedTo": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "delegatedTo": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "transaction_data": { "type": "harbour_delegate:data.purchase", "credential_ids": ["harbour_natural_person"], @@ -69,7 +69,7 @@ Evidence on a **receipt credential** (SD-JWT-VC) that a signing service executed "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", - "marketplace": "did:web:dataspace.envited.io" + "marketplace": "did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c" } }, "challenge": "da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52" @@ -153,7 +153,7 @@ When issuing a credential with evidence: credential = { "@context": [...], "type": ["VerifiableCredential", "harbour:NaturalPersonCredential"], - "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "credentialSubject": {...}, "evidence": [ { diff --git a/docs/specs/delegation-challenge-encoding.md b/docs/specs/delegation-challenge-encoding.md index dfa82d9..96af0a2 100644 --- a/docs/specs/delegation-challenge-encoding.md +++ b/docs/specs/delegation-challenge-encoding.md @@ -155,7 +155,7 @@ Important: `txn` keys are part of canonicalization and hashing. Renaming a key ( "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", - "marketplace": "did:web:dataspace.envited.io" + "marketplace": "did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c" } } ``` @@ -192,17 +192,17 @@ The delegated consent is captured as `evidence` in a Verifiable Credential or di { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiableCredential"], - "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "validFrom": "2026-02-24T12:00:00Z", "credentialSubject": { - "id": "did:webs:users.example.com:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP" + "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9" }, "evidence": [{ "type": ["CredentialEvidence"], "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:webs:users.example.com:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", + "holder": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", "verifiableCredential": [ "" ], @@ -211,8 +211,8 @@ The delegated consent is captured as `evidence` in a Verifiable Credential or di "cryptosuite": "ecdsa-rdfc-2019", "proofPurpose": "authentication", "challenge": "da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52", - "domain": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", - "verificationMethod": "did:webs:users.example.com:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP#key-1", + "domain": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "verificationMethod": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#key-1", "created": "2026-02-24T12:00:05Z", "proofValue": "z5vgFc..." } @@ -287,7 +287,7 @@ This specification is designed for seamless integration with [OpenID for Verifia "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", - "marketplace": "did:web:dataspace.envited.io" + "marketplace": "did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c" } } ``` @@ -299,7 +299,7 @@ Per OID4VP Appendix B.3.3, the KB-JWT includes: ```json { "nonce": "n-0S6_WzA2Mj", - "aud": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "aud": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "iat": 1709838604, "sd_hash": "Dy-RYwZfaaoC3inJbLslgPvMp09bH-clYP_3qbRqtW4", "transaction_data_hashes": ["7W0LFUTpMvb6nJK7ngamNNY0zNvxqJ-2jNXTmLzhWQE"], @@ -376,7 +376,7 @@ tx = TransactionData.create( "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", - "marketplace": "did:web:dataspace.envited.io", + "marketplace": "did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c", }, credential_ids=["harbour_natural_person"], ) @@ -405,7 +405,7 @@ const tx = createTransactionData({ asset_id: 'urn:uuid:550e8400-e29b-41d4-a716-446655440000', price: '100', currency: 'ENVITED', - marketplace: 'did:web:dataspace.envited.io', + marketplace: 'did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c', }, credentialIds: ['harbour_natural_person'], }); @@ -434,7 +434,7 @@ Following the design philosophy of [SIWE (EIP-4361)](https://eips.ethereum.org/E ║ Amount: 100 ENVITED ║ ║ ║ ╠═══════════════════════════════════════════════════════════════════════╣ -║ Service: did:webs:harbour.reachhaven.com:Er9_mnFst... ║ +║ Service: did:ethr:0x14a34:0x9c2f...c697 ║ ║ Nonce: da9b1009 ║ ║ Time: 2026-02-24 12:00:00 UTC ║ ╚═══════════════════════════════════════════════════════════════════════╝ @@ -509,7 +509,7 @@ These examples use the shared test vectors from `tests/fixtures/canonicalization "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", - "marketplace": "did:web:dataspace.envited.io" + "marketplace": "did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c" } } ``` @@ -557,7 +557,7 @@ ef567890 HARBOUR_DELEGATE 0736db89c15be412294f96717a3e435f89d095e7e953b1808c4222 "description": "Sign partnership agreement", "txn": { "document_hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "parties": ["did:webs:alice.example:EAbc123", "did:webs:bob.example:EDef456"] + "parties": ["did:ethr:0x14a34:0xAA11...2233", "did:ethr:0x14a34:0xBB44...5566"] } } ``` @@ -606,7 +606,7 @@ OID4VP authorization request: ```json { "response_type": "vp_token", - "client_id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "client_id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "nonce": "da9b1009", "transaction_data": [{ "type": "harbour_delegate:data.purchase", @@ -618,7 +618,7 @@ OID4VP authorization request: "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", - "marketplace": "did:web:dataspace.envited.io" + "marketplace": "did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c" } }] } diff --git a/docs/specs/did-method-evaluation.md b/docs/specs/did-method-evaluation.md index f87c64c..a27260c 100644 --- a/docs/specs/did-method-evaluation.md +++ b/docs/specs/did-method-evaluation.md @@ -1,305 +1,141 @@ -# DID Method Evaluation: did:web vs did:webs - -**Version**: 2.0.0 -**Date**: 2026-02-26 -**Status**: Decision Record - ---- - -## 1. Executive Summary - -This document evaluates `did:web` and `did:webs` DID methods for use in Harbour Credentials. - -**Decision**: Use `did:webs` for all Harbour identities (infrastructure and participants). The wallet-transparent KERI architecture (§8) enables `did:webs` without requiring wallet-side KERI support. - ---- - -## 2. Overview - -| Method | Description | -|--------|-------------| -| **did:web** | DID method that uses web domains for identifier resolution. DID documents are hosted as JSON files at well-known URLs. | -| **did:webs** | Extension of did:web that adds KERI (Key Event Receipt Infrastructure) for cryptographically verifiable key history and rotation. | - ---- - -## 3. Feature Comparison - -| Feature | did:web | did:webs | -|---------|---------|----------| -| **Web hosting** | ✅ Simple HTTPS | ✅ HTTPS + KERI AID | -| **Key rotation** | Manual update to did.json | ✅ Cryptographic key event log (KEL) | -| **Key history** | ❌ No verifiable history | ✅ Full verifiable history via KERI | -| **Revocation audit** | Trust web server | ✅ Cryptographically verifiable | -| **Offline verification** | ❌ Requires live fetch | ✅ Can verify with cached KEL | -| **Compromise recovery** | ❌ Difficult (trust server) | ✅ Pre-rotation keys | -| **Spec status** | W3C CCG Stable | ToIP Draft (active development) | -| **Complexity** | Low | High (KERI infrastructure) | - ---- - -## 4. Tooling Maturity (as of 2026-02) - -### 4.1 Libraries - -| Tool | did:web | did:webs | -|------|---------|----------| -| **Python** | Multiple (did-resolver, etc.) | `keri` v1.3.4 on PyPI ✅ | -| **TypeScript/JS** | did-resolver, veramo | Limited | -| **Universal Resolver** | ✅ Supported | ✅ Supported | - -### 4.2 Implementations - -| Implementation | Stars | Status | Notes | -|----------------|-------|--------|-------| -| **keripy** (WebOfTrust) | 74★ | Active (v2.0.0-dev5) | Core KERI Python library | -| **did-webs-resolver** (Hyperledger Labs) | 13★ | Active | Reference resolver | -| **Veridian Wallet** (Cardano Foundation) | 139★ | Active | KERI-native mobile wallet | - -### 4.3 Specification Status - -| Spec | Organization | Status | Last Update | -|------|--------------|--------|-------------| -| **did:web** | W3C CCG | Stable | 2023 | -| **did:webs** | Trust Over IP | Draft | Feb 2026 | -| **KERI** | WebOfTrust/IETF | Draft | Active | - ---- - -## 5. did:webs Advantages - -### 5.1 Cryptographic Key History - -With did:web, when a key is rotated, the old key is simply replaced. There's no cryptographic proof of what the previous key was or when it was rotated. - -With did:webs, every key event (rotation, revocation) is recorded in a Key Event Log (KEL) that is cryptographically chained: +# DID Method Evaluation: did:ethr + +> **Decision**: Harbour uses `did:ethr` (ERC-1056 / EthereumDIDRegistry) on **Base** (L2 rollup) +> as its primary DID method, replacing the previously evaluated `did:web` and `did:webs`. +> +> This document summarizes the evaluation and rationale. + +## Glossary + +| Term | Definition | +|------|-----------| +| **did:ethr** | DID method anchored on Ethereum-compatible blockchains via ERC-1056 (EthereumDIDRegistry). Supports key rotation, delegate management, and attribute registration through on-chain events. | +| **did:web** | *(Superseded)* DID method that uses web domains for identifier resolution. DID documents hosted as JSON files at well-known URLs. | +| **did:webs** | *(Superseded)* Extension of did:web that adds KERI for cryptographically verifiable key history. | +| **did:key** | Ephemeral DID method encoding a single public key. Used for testing and wallet-generated identifiers. | +| **ERC-1056** | Ethereum Improvement Proposal defining the EthereumDIDRegistry smart contract. | +| **Base** | Coinbase L2 rollup on Ethereum, providing low-cost transactions with Ethereum security. | + +## Why did:ethr? + +### Comparison with Previous Methods + +| Feature | did:web | did:webs | did:ethr | +|---------|---------|----------|----------| +| Resolution | HTTPS fetch | HTTPS + KERI | On-chain events | +| Key rotation | Replace file | KEL append | On-chain `changeOwner` / `setAttribute` | +| Revocation | Delete document | KEL revocation | `revokeDelegate` / `changeOwner(0x0)` | +| Offline verification | ❌ | ✅ (via KEL) | ✅ (via cached events) | +| Infrastructure | Web server | Web server + KERI node | EVM node (public RPCs available) | +| Decentralisation | ❌ (DNS/TLS) | Partial (KERI witnesses) | ✅ (blockchain) | +| P-256 support | Native | Native | Via `setAttribute()` (delegate keys) | +| Wallet support | Broad | Limited (KERI wallets) | Broad (ethers.js, MetaMask, etc.) | +| Cost per operation | Free (hosting) | Free (hosting) | Gas fees (low on Base L2) | + +### Key Advantages + +1. **No web server dependency** — DID documents are resolved from on-chain events, not HTTPS endpoints +2. **Cryptographic key history** — All key changes are permanently recorded on-chain +3. **True decentralisation** — No reliance on DNS or TLS certificate authorities +4. **P-256 key registration** — Custom smart contract registers P-256 keys as on-chain attributes +5. **Low cost on Base** — L2 gas fees are orders of magnitude cheaper than Ethereum mainnet +6. **Broad ecosystem support** — `ethr-did-resolver` available for JS/TS, Python resolver libraries available + +## DID Format ``` -Inception Event → Rotation Event 1 → Rotation Event 2 → ... -``` - -Each event is signed by the previous key, creating an unbroken chain of custody. - -### 5.2 Pre-rotation (Compromise Recovery) - -did:webs supports **pre-rotation**: when creating a key, you also commit to the hash of the next key. If your current key is compromised, the attacker cannot rotate to their own key because they don't know your pre-committed next key. - -### 5.3 Offline Verification - -With did:web, verifiers must fetch the current DID document from the web server each time. With did:webs, the KEL can be cached and verified offline—the cryptographic chain provides assurance even without network access. - ---- - -## 6. did:webs Concerns - -### 6.1 Specification Maturity - -- No formal 1.0 release from Trust Over IP -- Still evolving (breaking changes possible) -- Limited interoperability testing - -### 6.2 Operational Complexity - -did:webs requires KERI infrastructure: - -- **Witnesses**: Nodes that sign and store key events (for availability) -- **Watchers**: Nodes that monitor for duplicity (for security) -- **KERI Agent**: Software to manage key events - -This is significantly more complex than hosting a `did.json` file. +did:ethr::
-### 6.3 Wallet Support - -| Wallet | did:web | did:webs | -|--------|---------|----------| -| Altme | ✅ | ❌ | -| Sphereon | ✅ | ❌ | -| walt.id | ✅ | ❌ | -| Veridian | ❌ | ✅ | - -Most VC wallets support did:web natively. did:webs support is limited to KERI-specific wallets. - ---- - -## 7. Current Harbour Implementation (did:webs) - -Harbour uses `did:webs` identifiers for all entities. The wallet-transparent -KERI architecture (§8) provides cryptographic key history without requiring -wallet-side KERI support. - -### 7.1 DID Structure - -From [`examples/did-webs/`](../../examples/did-webs/): - -| Entity | DID | Keys | -|--------|-----|------| -| Trust Anchor | `did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo` | `#key-1` (assertionMethod) | -| Signing Service | `did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ` | `#key-1` (assertionMethod), `#key-2` (capabilityDelegation) | -| Participants | `did:webs:participants.harbour.reachhaven.com:legal-persons::` | `#key-1` (assertionMethod) | -| Users | `did:webs:users.altme.example:natural-persons::` | `#key-1` (assertionMethod) | - -### 7.2 Trust Model - -- Trust Anchor (`did:webs:reachhaven.com:ENVSnGVU...`) is the root of trust -- Signing Service is the sole credential issuer, authorized via evidence VPs -- Trust Anchor has a `LinkedCredentialService` endpoint for its self-signed credential -- Naming policy: all DID paths use UUID segments (never real names or org names) - -### 7.3 Credential Issuance Chain - -1. Trust Anchor authorizes org → VP with self-signed LegalPersonCredential -2. Org authorizes employee → VP with org's LegalPersonCredential (SD-JWT, PII redacted) -3. Signing Service issues all credentials with authorization VPs as evidence - ---- - -## 8. Wallet-Transparent did:webs Architecture - -A key architectural insight: **wallets don't need native KERI support** if Harbour operates the KERI infrastructure. - -### 8.1 The Insight - -KERI key events are just signed messages. Any wallet that can sign with ES256/P-256 can sign a KERI rotation event—it doesn't need to "understand" KERI semantics. +# Base Sepolia Testnet (development) +did:ethr:0x14a34:0x71C7656EC7ab88b098defB751B7401B5f6d8976F +# Base Mainnet (production) +did:ethr:0x2105:0x71C7656EC7ab88b098defB751B7401B5f6d8976F ``` -┌─────────────────────────────────────────────────────────────────┐ -│ HARBOUR │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ -│ │ KERI │ │ Witnesses │ │ did:webs Resolution │ │ -│ │ Agent │ │ (3+ nodes) │ │ & KEL Management │ │ -│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────┘ - ▲ - │ Signs rotation events, - │ VPs, etc. (just ES256) - │ -┌─────────────────────────────────────────────────────────────────┐ -│ ANY VC WALLET │ -│ │ -│ ┌──────────────────┐ Wallet only needs to: │ -│ │ P-256 Key │ ✓ Hold private key │ -│ │ (ES256) │ ✓ Sign payloads when asked │ -│ └──────────────────┘ ✗ No KERI awareness needed │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -### 8.2 Protocol Flow -| Step | Wallet Action | Harbour Action | -|------|---------------|----------------| -| **DID Creation** | Generate P-256 keypair, share public key | Create KERI inception event, publish to witnesses | -| **Normal Use** | Sign VPs with P-256 key | Resolve did:webs, verify signatures against KEL | -| **Key Rotation** | Sign rotation payload with OLD key | Construct rotation event, coordinate witnesses, update KEL | -| **Verification** | (nothing) | Full KERI verification with key history | - -### 8.3 Rotation Protocol - -When a user needs to rotate their key: - -1. **User** generates new P-256 keypair in wallet -2. **Harbour** constructs KERI rotation event payload -3. **Harbour** sends payload to wallet for signing (standard ES256 signature request) -4. **Wallet** signs with OLD key (wallet doesn't know this is "KERI"—it's just a signature) -5. **Harbour** publishes signed rotation event to KERI witnesses -6. **Harbour** updates did:webs document - -The wallet's view: "Harbour asked me to sign something, I signed it." - -### 8.4 Implications - -| Concern | Resolution | -|---------|------------| -| Wallet support | ✅ Any ES256-capable wallet works | -| Complexity | Contained in Harbour infrastructure | -| User experience | No change from did:web | -| Cryptographic guarantees | Full KERI benefits (verifiable key history) | -| Operational burden | Harbour operates witnesses (can be distributed) | - -### 8.5 Considerations - -1. **Trust**: Users must trust Harbour to correctly manage their KERI events -2. **Availability**: Harbour witnesses must be highly available -3. **Signing UX**: Wallet must support signing arbitrary payloads (most do) -4. **Pre-rotation**: Still requires Harbour to manage pre-rotation commitments - -This architecture provides KERI's cryptographic benefits while maintaining compatibility with the existing wallet ecosystem. - ---- - -## 9. Migration Status - -Migration to `did:webs` is complete for identity modeling. All example -identities, DID documents, and credential examples now use `did:webs`. - -### Completed - -- [x] All Harbour infrastructure DIDs use `did:webs` (Trust Anchor, Signing Service) -- [x] All participant/user DIDs use `did:webs` with UUID paths -- [x] DID documents created for all actors (`examples/did-webs/`) -- [x] Credential examples updated with `did:webs` issuers and subjects -- [x] Wallet-transparent architecture designed (§8) — any ES256 wallet works - -### Remaining Infrastructure Work - -- [ ] KERI witness infrastructure deployed (Harbour-operated, 3+ witnesses recommended) -- [ ] Rotation signing protocol implemented in Harbour -- [ ] did:webs resolver integrated for production verification (or use Universal Resolver) - ---- - -## 10. Recommendation +## DID Document Resolution + +The `ethr-did-resolver` reconstructs DID documents by reading `DIDAttributeChanged`, +`DIDDelegateChanged`, and `DIDOwnerChanged` events from the EthereumDIDRegistry contract. + +```json +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/secp256k1recovery-2020/v2" + ], + "id": "did:ethr:0x14a34:0x71C7656EC7ab88b098defB751B7401B5f6d8976F", + "controller": "did:ethr:0x14a34:0xC0FFEEbabe000000000000000000000000000001", + "verificationMethod": [ + { + "id": "...#controller", + "type": "EcdsaSecp256k1RecoveryMethod2020", + "controller": "...", + "blockchainAccountId": "eip155:84532:0x71C7656EC7ab88b098defB751B7401B5f6d8976F" + }, + { + "id": "...#delegate-1", + "type": "JsonWebKey", + "controller": "...", + "publicKeyJwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." } + } + ], + "authentication": ["...#controller", "...#delegate-1"], + "assertionMethod": ["...#controller", "...#delegate-1"] +} +``` -### Current +## Key Management -**Use did:webs** with the wallet-transparent KERI architecture (§8): +| Context | DID Method | kid Format | +|---------|-----------|------------| +| **EUDI** | X.509 | `x5c` header (no kid) | +| **Gaia-X** | `did:ethr` | `did:ethr:0x14a34:
#delegate-1` | +| **Testing** | `did:key` | `did:key:zDn...#zDn...` | -1. ✅ P-256 keys (ES256 algorithm) -2. ✅ Stable fragment IDs for key references (`#key-1`, `#key-2`) -3. ✅ Trust Anchor with self-signed credential (root of trust) -4. ✅ Signing Service as sole credential issuer -5. ✅ UUID-only DID paths (privacy-preserving) -6. ✅ Wallet-transparent KERI (any ES256 wallet works) +### Identity Architecture -### Remaining Infrastructure Work +| Role | DID Pattern | Key Usage | +|------|-------------|-----------| +| Signing Service | `did:ethr:0x14a34:` | `#delegate-1` (assertionMethod), `#delegate-2` (capabilityDelegation) | +| Trust Anchor | `did:ethr:0x14a34:` | `#delegate-1` (assertionMethod) | +| Participants | `did:ethr:0x14a34:` | `#delegate-1` (assertionMethod) | +| Users | `did:ethr:0x14a34:` | `#delegate-1` (assertionMethod) | -- [ ] KERI witness infrastructure deployed (Harbour-operated, 3+ witnesses) -- [ ] Rotation signing protocol implemented -- [ ] did:webs resolver integrated for production verification -- [x] ~~3+ wallets support did:webs~~ **NOT REQUIRED** — wallet-transparent architecture +## Network Configuration ---- +| Network | Chain ID | Hex | Use | +|---------|----------|-----|-----| +| Base Sepolia | 84532 | 0x14a34 | Development, testing | +| Base Mainnet | 8453 | 0x2105 | Production | -## 11. References +### RPC Endpoints -### Specifications +- **Sepolia**: `https://sepolia.base.org` +- **Mainnet**: `https://mainnet.base.org` -- [did:web Method Specification](https://w3c-ccg.github.io/did-method-web/) (W3C CCG) -- [did:webs Method Specification](https://trustoverip.github.io/tswg-did-method-webs-specification/) (Trust Over IP) -- [KERI Specification](https://weboftrust.github.io/ietf-keri/draft-ssmith-keri.html) (IETF Draft) -- [W3C DID Core](https://www.w3.org/TR/did-core/) (W3C Recommendation) +## Migration from did:web / did:webs -### Implementations +The migration from did:web/did:webs to did:ethr involves: -- [keripy](https://github.com/WebOfTrust/keripy) - Python KERI implementation -- [did-webs-resolver](https://github.com/hyperledger-labs/did-webs-resolver) - Hyperledger Labs -- [Veridian Wallet](https://github.com/cardano-foundation/veridian-wallet) - KERI-native wallet +1. **Deriving Ethereum addresses** from existing P-256 key material +2. **Registering P-256 keys** as on-chain attributes via `setAttribute()` +3. **Updating all credential examples** to use `did:ethr` identifiers +4. **Deploying EthereumDIDRegistry** (or using existing deployment) on Base -### Local Copies +See `examples/did-ethr/` for migrated DID document examples. -Reference specifications are stored in `docs/specs/references/` for offline access: +## References -- `did-web-method.txt` - did:web specification -- `did-webs-spec.md` - did:webs specification (concatenated) -- `keri-draft.md` - KERI IETF draft -- `oid4vp-1.0.txt` - OpenID4VP specification +- [did:ethr Method Specification](https://github.com/decentralized-identity/ethr-did-resolver/blob/master/doc/did-method-spec.md) (DIF) +- [ERC-1056: Ethereum Lightweight Identity](https://eips.ethereum.org/EIPS/eip-1056) +- [ethr-did-resolver](https://github.com/decentralized-identity/ethr-did-resolver) (JavaScript) +- [Base Documentation](https://docs.base.org/) ---- +### Archived Specifications -## 12. Version History +These specifications are retained for historical reference but are no longer the active DID method: -| Version | Date | Changes | -|---------|------|---------| -| 2.0.0 | 2026-02-26 | Migrated to did:webs; updated section 7 for current implementation | -| 1.1.0 | 2026-02-24 | Updated recommendation based on wallet-transparent KERI insight | -| 1.0.0 | 2026-02-24 | Initial evaluation and decision | +- `did-web-method.txt` — did:web specification (W3C CCG) *(superseded)* +- `did-webs-spec.md` — did:webs specification (ToIP) *(superseded)* +- `did-ethr-method-spec.md` — did:ethr method specification (active) diff --git a/docs/specs/references/README.md b/docs/specs/references/README.md index cf739ad..9f5ba6f 100644 --- a/docs/specs/references/README.md +++ b/docs/specs/references/README.md @@ -16,8 +16,9 @@ They are copies of specifications published by their respective standards organi | `vc-jose-cose.md` | [VC-JOSE-COSE](https://www.w3.org/TR/vc-jose-cose/) | W3C | [W3C Document License](https://www.w3.org/copyright/document-license-2023/) | | `sd-jwt-vc.md` | [SD-JWT-VC draft-14](https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/) | IETF | [IETF Trust](https://trustee.ietf.org/license-info) | | `csc-data-model.md` | [CSC Data Model v1.0.0](https://cloudsignatureconsortium.org/wp-content/uploads/2025/10/data-model-bindings.pdf) | Cloud Signature Consortium | CSC License | -| `did-web-method.txt` | [did:web Specification](https://w3c-ccg.github.io/did-method-web/) | W3C CCG | [W3C Document License](https://www.w3.org/copyright/document-license-2023/) | -| `did-webs-spec.md` | [did:webs Specification](https://github.com/trustoverip/tswg-did-method-webs-specification) | Trust Over IP Foundation | [ToIP License](https://github.com/trustoverip/tswg-did-method-webs-specification/blob/main/LICENSE.md) | +| `did-web-method.txt` | [did:web Specification](https://w3c-ccg.github.io/did-method-web/) *(superseded by did:ethr)* | W3C CCG | [W3C Document License](https://www.w3.org/copyright/document-license-2023/) | +| `did-ethr-method-spec.md` | [did:ethr Method Specification](https://github.com/decentralized-identity/ethr-did-resolver/blob/master/doc/did-method-spec.md) | DIF | Apache-2.0 | +| `did-webs-spec.md` | [did:ethr Specification](https://github.com/trustoverip/tswg-did-method-webs-specification) | Trust Over IP Foundation | [ToIP License](https://github.com/trustoverip/tswg-did-method-webs-specification/blob/main/LICENSE.md) | | `keri-draft.md` | [KERI Draft](https://github.com/WebOfTrust/ietf-keri) | WebOfTrust / IETF | Apache 2.0 | ## Download Date @@ -42,11 +43,11 @@ To update these references: curl -sL "https://openid.net/specs/openid-4-verifiable-presentations-1_0.html" | \ python3 -c "..." > oid4vp-1.0.txt -# did:web +# did:web *(superseded by did:ethr)* curl -sL "https://w3c-ccg.github.io/did-method-web/" | \ python3 -c "..." > did-web-method.txt -# did:webs (from GitHub) +# did:ethr (from GitHub) # See download script in repository # KERI @@ -59,8 +60,9 @@ curl -sL "https://raw.githubusercontent.com/WebOfTrust/ietf-keri/main/draft-ssmi Always refer to the original sources for the most up-to-date and legally binding versions: - **OpenID4VP**: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html -- **did:web**: https://w3c-ccg.github.io/did-method-web/ -- **did:webs**: https://trustoverip.github.io/tswg-did-method-webs-specification/ +- **did:web** *(superseded)*: https://w3c-ccg.github.io/did-method-web/ +- **did:ethr**: https://github.com/decentralized-identity/ethr-did-resolver/blob/master/doc/did-method-spec.md +- **did:ethr**: https://trustoverip.github.io/tswg-did-method-webs-specification/ - **KERI**: https://weboftrust.github.io/ietf-keri/draft-ssmith-keri.html - **W3C DID Core**: https://www.w3.org/TR/did-core/ - **W3C VC Data Model**: https://www.w3.org/TR/vc-data-model-2.0/ diff --git a/docs/specs/references/did-ethr-method-spec.md b/docs/specs/references/did-ethr-method-spec.md new file mode 100644 index 0000000..230dbc6 --- /dev/null +++ b/docs/specs/references/did-ethr-method-spec.md @@ -0,0 +1,591 @@ +# ETHR DID Method Specification + +## Author + +- Veramo core team: or veramo-hello@mesh.xyz + +## Preface + +The ethr DID method specification conforms to the requirements specified in +the [DID specification](https://w3c-ccg.github.io/did-core/), currently published by the W3C Credentials Community +Group. For more information about DIDs and DID method specifications, please see +the [DID Primer](https://github.com/WebOfTrustInfo/rebooting-the-web-of-trust-fall2017/blob/master/topics-and-advance-readings/did-primer.md) + +## Abstract + +Decentralized Identifiers (DIDs, see [1]) are designed to be compatible with any distributed ledger or network. In the +Ethereum community, a pattern known as ERC1056 (see [2]) utilizes a smart contract for a lightweight identifier +management system intended explicitly for off-chain usage. + +The described DID method allows any Ethereum smart contract or key pair account, or any secp256k1 public key to become +a valid identifier. Such an identifier needs no registration. In case that key management or additional attributes such +as "service endpoints" are required, they are resolved using ERC1056 smart contracts deployed on the networks listed in +the [registry repository](https://github.com/uport-project/ethr-did-registry#contract-deployments). + +Most networks use the default registry address: `0xdca7ef03e98e0dc2b855be647c39abe984fcf21b`. + +Since each Ethereum transaction must be funded, there is a growing trend of on-chain transactions that are authenticated +via an externally created signature and not by the actual transaction originator. This allows for 3rd party funding +services, or for receivers to pay without any fundamental changes to the underlying Ethereum architecture. These kinds +of transactions have to be signed by an actual key pair and thus cannot be used to represent smart contract based +Ethereum accounts. ERC1056 proposes a way of a smart contract or regular key pair delegating signing for various +purposes to externally managed key pairs. This allows a smart contract to be represented, both on-chain and +off-chain or in payment channels through temporary or permanent delegates. + +For a reference implementation of this DID method specification see [3]. + +### Identifier Controller + +By default, each identifier is controlled by itself, or rather by its corresponding Ethereum address. Each identifier +can only be controlled by a single ethereum address at any given time. The controller can replace themselves with any +other Ethereum address, including contracts to allow more advanced models such as multi-signature control. + +## Target System + +The target system is the Ethereum network where the ERC1056 is deployed. This could either be: + +- Mainnet +- Goerli +- other EVM-compliant blockchains such as private chains, side-chains, or consortium chains. + +### Advantages + +- No transaction fee for identifier creation +- Identifier creation is private +- Uses Ethereum's built-in account abstraction +- Supports multi-sig (or proxy) wallet for account controller +- Supports secp256k1 public keys as identifiers (on the same infrastructure) +- Decoupling claims data from the underlying identifier +- Supports decoupling Ethereum interaction from the underlying identifier +- Flexibility to use key management +- Flexibility to allow third-party funding service to pay the gas fee if needed (meta-transactions) +- Supports any EVM-compliant blockchain +- Supports verifiable versioning + +## JSON-LD Context Definition + +Since this DID method still supports `publicKeyHex` and `publicKeyBase64` encodings for verification methods, it +requires a valid JSON-LD context for those entries. +To enable JSON-LD processing, the `@context` used when constructing DID documents for `did:ethr` should be: + +``` +"@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/secp256k1recovery-2020/v2" +] +``` + +You will also need this `@context` if you need to use `EcdsaSecp256k1RecoveryMethod2020` in your apps. + +## DID Method Name + +The namestring that shall identify this DID method is: `ethr` + +A DID that uses this method MUST begin with the following prefix: `did:ethr`. Per the DID specification, this string +MUST be in lowercase. The remainder of the DID, after the prefix, is specified below. + +## Method Specific Identifier + +The method specific identifier is represented as the HEX-encoded secp256k1 public key (in compressed form), +or the corresponding HEX-encoded Ethereum address on the target network, prefixed with `0x`. + + ethr-did = "did:ethr:" ethr-specific-identifier + ethr-specific-identifier = [ ethr-network ":" ] ethereum-address / public-key-hex + ethr-network = "mainnet" / "goerli" / network-chain-id + network-chain-id = "0x" *HEXDIG + ethereum-address = "0x" 40*HEXDIG + public-key-hex = "0x" 66*HEXDIG + +The `ethereum-address` or `public-key-hex` are case-insensitive, however, the corresponding `blockchainAccountId` +MAY be represented using +the [mixed case checksum representation described in EIP55](https://github.com/ethereum/EIPs/blob/master/EIPS/eip-55.md) +in the resulting DID document. + +Note, if no public Ethereum network was specified, it is assumed that the DID is anchored on the Ethereum mainnet by +default. This means the following DIDs will resolve to equivalent DID Documents: + + did:ethr:mainnet:0xb9c5714089478a327f09197987f16f9e5d936e8a + did:ethr:0x1:0xb9c5714089478a327f09197987f16f9e5d936e8a + did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a + +If the identifier is a `public-key-hex`: + +- it MUST be represented in compressed form (see https://en.bitcoin.it/wiki/Secp256k1) +- the corresponding `blockchainAccountId` entry is also added to the default DID document, unless the `owner` property + has been changed to a different address. +- all Read, Update, and Delete operations MUST be made using the corresponding `blockchainAccountId` and MUST originate + from the correct controller account (ECR1056 `owner`). + +## Relationship to ERC1056 + +The subject of a `did:ethr` is mapped to an `identity` Ethereum address in the ERC1056 contract. When dealing with +public key identifiers, the Ethereum address corresponding to that public key is used to represent the controller. + +The controller address of a `did:ethr` is mapped to the `owner` of an `identity` in the ERC1056. +The controller address is not listed as the [DID `controller`](https://www.w3.org/TR/did-core/#did-controller) property +in the DID document. This is intentional, to simplify the verification burden required by the DID spec. +Rather, this address it is a concept specific to ERC1056 and defines the address that is allowed to perform Update and +Delete operations on the registry on behalf of the `identity` address. +This address MUST be listed with the ID `${did}#controller` in the `verificationMethod` section and also referenced +in all other verification relationships listed in the DID document. +In addition to this, if the identifier is a public key, this public key MUST be listed with the +ID `${did}#controllerKey` in all locations where `#controller` appears. + +## CRUD Operation Definitions + +### Create (Register) + +In order to create a `ethr` DID, an Ethereum address, i.e., key pair, needs to be generated. At this point, no +interaction with the target Ethereum network is required. The registration is implicit as it is impossible to brute +force an Ethereum address, i.e., guessing the private key for a given public key on the Koblitz Curve +(secp256k1). The holder of the private key is the entity identified by the DID. + +The default DID document for an `did:ethr` on mainnet, e.g. +`did:ethr:0xf3beac30c498d9e26865f34fcaa57dbb935b0d74` with no transactions to the ERC1056 registry looks like this: + +```json +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/secp256k1recovery-2020/v2" + ], + "id": "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a", + "verificationMethod": [ + { + "id": "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a#controller", + "type": "EcdsaSecp256k1RecoveryMethod2020", + "controller": "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a", + "blockchainAccountId": "eip155:1:0xb9c5714089478a327f09197987f16f9e5d936e8a" + } + ], + "authentication": [ + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a#controller" + ], + "assertionMethod": [ + "did:ethr:0xb9c5714089478a327f09197987f16f9e5d936e8a#controller" + ] +} +``` + +The minimal DID Document for a `did:ethr:` where there are no corresponding TXs to the ERC1056 registry +looks like this: + +```json +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/secp256k1recovery-2020/v2" + ], + "id": "did:ethr:0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "verificationMethod": [ + { + "id": "did:ethr:0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798#controller", + "type": "EcdsaSecp256k1RecoveryMethod2020", + "controller": "did:ethr:0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "blockchainAccountId": "eip155:1:0xb9c5714089478a327f09197987f16f9e5d936e8a" + }, + { + "id": "did:ethr:0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798#controllerKey", + "type": "EcdsaSecp256k1VerificationKey2019", + "controller": "did:ethr:0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "publicKeyHex": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + } + ], + "authentication": [ + "did:ethr:0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798#controller", + "did:ethr:0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798#controllerKey" + ], + "assertionMethod": [ + "did:ethr:0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798#controller", + "did:ethr:0x0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798#controllerKey" + ] +} +``` + +### Read (Resolve) + +The DID document is built by using read only functions and contract events on the ERC1056 registry. + +Any value from the registry that returns an Ethereum address will be added to the `verificationMethod` array of the +DID document with type `EcdsaSecp256k1RecoveryMethod2020` and a `blockchainAccountId` attribute containing the address +using [CAIP10 Format](https://github.com/ChainAgnostic/CAIPs/blob/master/CAIPs/caip-10.md). + +Other verification relationships and service entries are added or removed by enumerating contract events (see below). + +#### Controller Address + +Each identifier always has a controller address. By default, it is the same as the identifier address, but the resolver +MUST check the read only contract function `identityOwner(address identity)` on the deployed ERC1056 contract. + +This controller address MUST be represented in the DID document as a `verificationMethod` entry with the `id` set as the +DID being resolved and with the fragment `#controller` appended to it. +A reference to it MUST also be added to the `authentication` and `assertionMethod` arrays of the DID document. + +#### Enumerating Contract Events to build the DID Document + +The ERC1056 contract publishes three types of events for each identifier. + +- `DIDOwnerChanged` (indicating a change of `controller`) +- `DIDDelegateChanged` +- `DIDAttributeChanged` + +If a change has ever been made for the Ethereum address of an identifier the block number is stored in the +`changed` mapping of the contract. + +The latest event can be efficiently looked up by checking for one of the 3 above events at that exact block. + +Each ERC1056 event contains a `previousChange` value which contains the block number of the previous change (if any). + +To see all changes in history for an address use the following pseudo-code: + +1. eth_call `changed(address identity)` on the ERC1056 contract to get the latest block where a change occurred. +2. If result is `null` return. +3. Filter for events for all the above types with the contracts address on the specified block. +4. If event has a previous change then go to 3 + +After building the history of events for an address, interpret each event to build the DID document like so: + +##### Controller changes (`DIDOwnerChanged`) + +When the controller address of a `did:ethr` is changed, a `DIDOwnerChanged` event is emitted. + +```solidity +event DIDOwnerChanged( + address indexed identity, + address owner, + uint previousChange +); +``` + +The event data MUST be used to update the `#controller` entry in the `verificationMethod` array. +When resolving DIDs with publicKey identifiers, if the controller (`owner`) address is different from the corresponding +address of the publicKey, then the `#controllerKey` entry in the `verificationMethod` array MUST be omitted. + +##### Delegate Keys (`DIDDelegateChanged`) + +Delegate keys are Ethereum addresses that can either be general signing keys or optionally also perform authentication. + +They are also verifiable from Solidity (on-chain). + +When a delegate is added or revoked, a `DIDDelegateChanged` event is published that MUST be used to update the DID +document. + +```solidity +event DIDDelegateChanged( + address indexed identity, + bytes32 delegateType, + address delegate, + uint validTo, + uint previousChange +); +``` + +The only 2 `delegateTypes` that are currently published in the DID document are: + +- `veriKey` which adds a `EcdsaSecp256k1RecoveryMethod2020` to the `verificationMethod` section of the DID document with + the `blockchainAccountId`(`ethereumAddress`) of the delegate, and adds a reference to it in the `assertionMethod` + section. +- `sigAuth` which adds a `EcdsaSecp256k1RecoveryMethod2020` to the `verificationMethod` section of document and a + reference to it in the `authentication` section. + +Note, the `delegateType` is a `bytes32` type for Ethereum gas efficiency reasons and not a `string`. This restricts us +to 32 bytes, which is why we use the shorthand versions above. + +Only events with a `validTo` (measured in seconds) greater or equal to the current time should be included in the DID +document. When resolving an older version (using `versionId` in the didURL query string), the `validTo` entry MUST be +compared to the timestamp of the block of `versionId` height. + +Such valid delegates MUST be added to the `verificationMethod` array as `EcdsaSecp256k1RecoveryMethod2020` entries, with +the `delegate` address listed in the `blockchainAccountId` property and prefixed with `eip155::`, according +to [CAIP10](https://github.com/ChainAgnostic/CAIPs/blob/master/CAIPs/caip-10.md) + +Example: + +```json +{ + "id": "did:ethr:0xf3beac30c498d9e26865f34fcaa57dbb935b0d74#delegate-1", + "type": "EcdsaSecp256k1RecoveryMethod2020", + "controller": "did:ethr:0xf3beac30c498d9e26865f34fcaa57dbb935b0d74", + "blockchainAccountId": "eip155:1:0x12345678c498d9e26865f34fcaa57dbb935b0d74" +} +``` + +##### Non-Ethereum Attributes (`DIDAttributeChanged`) + +Non-Ethereum keys, service endpoints etc. can be added using attributes. Attributes only exist on the blockchain as +contract events of type `DIDAttributeChanged` and can thus not be queried from within solidity code. + +```solidity +event DIDAttributeChanged( + address indexed identity, + bytes32 name, + bytes value, + uint validTo, + uint previousChange +); +``` + +Note, the name is a `bytes32` type for Ethereum gas efficiency reasons and not a `string`. This restricts us to 32 +bytes, which is why we use the shorthand attribute versions explained below. + +While any attribute can be stored, for the DID document we support adding to each of these sections of the DID document: + +- Public Keys (Verification Methods) +- Service Endpoints + +This design decision is meant to discourage the use of custom attributes in DID documents as they would be too easy to +misuse for storing personal user information on-chain. + +###### Public Keys + +The name of the attribute added to ERC1056 should follow this format: +`did/pub/(Secp256k1|RSA|Ed25519|X25519)/(veriKey|sigAuth|enc)/(hex|base64|base58)` + +(Essentially `did/pub///`) +Please opt for the `base58` encoding since the other encodings are not spec compliant and will be removed in future +versions of the spec and reference resolver. + +###### Key purposes + +- `veriKey` adds a verification key to the `verificationMethod` section of document and adds a reference to it in + the `assertionMethod` section of document. +- `sigAuth` adds a verification key to the `verificationMethod` section of document and adds a reference to it in + the `authentication` section of document. +- `enc` adds a key agreement key to the `verificationMethod` section and a corresponding entry to the `keyAgreement` + section. + This is used to perform a Diffie-Hellman key exchange and derive a secret key for encrypting messages to the DID that + lists such a key. + +> **Note** The `` only refers to the key encoding in the resolved DID document. +> Attribute values sent to the ERC1056 registry should always be hex encodings of the raw public key data. + +###### Example Hex encoded Secp256k1 Verification Key + +A `DIDAttributeChanged` event for the account `0xf3beac30c498d9e26865f34fcaa57dbb935b0d74` with the name +`did/pub/Secp256k1/veriKey/hex` and the value of `0x02b97c30de767f084ce3080168ee293053ba33b235d7116a3263d29f1450936b71` +generates a verification method entry like the following: + +```json +{ + "id": "did:ethr:0xf3beac30c498d9e26865f34fcaa57dbb935b0d74#delegate-1", + "type": "EcdsaSecp256k1VerificationKey2019", + "controller": "did:ethr:0xf3beac30c498d9e26865f34fcaa57dbb935b0d74", + "publicKeyHex": "02b97c30de767f084ce3080168ee293053ba33b235d7116a3263d29f1450936b71" +} +``` + +###### Example Base58 encoded Ed25519 Verification Key + +A `DIDAttributeChanged` event for the account `0xf3beac30c498d9e26865f34fcaa57dbb935b0d74` with the name +`did/pub/Ed25519/veriKey/base58` and the value of `0xb97c30de767f084ce3080168ee293053ba33b235d7116a3263d29f1450936b71` +generates a verification method entry like this: + +```json +{ + "id": "did:ethr:0xf3beac30c498d9e26865f34fcaa57dbb935b0d74#delegate-1", + "type": "Ed25519VerificationKey2018", + "controller": "did:ethr:0xf3beac30c498d9e26865f34fcaa57dbb935b0d74", + "publicKeyBase58": "DV4G2kpBKjE6zxKor7Cj21iL9x9qyXb6emqjszBXcuhz" +} +``` + +###### Example Base64 encoded X25519 Encryption Key + +A `DIDAttributeChanged` event for the account `0xf3beac30c498d9e26865f34fcaa57dbb935b0d74` with the name +`did/pub/X25519/enc/base64` and the value of +`0x302a300506032b656e032100118557777ffb078774371a52b00fed75561dcf975e61c47553e664a617661052` +generates a verification method entry like this: + +```json +{ + "id": "did:ethr:0xf3beac30c498d9e26865f34fcaa57dbb935b0d74#delegate-1", + "type": "X25519KeyAgreementKey2019", + "controller": "did:ethr:0xf3beac30c498d9e26865f34fcaa57dbb935b0d74", + "publicKeyBase64": "MCowBQYDK2VuAyEAEYVXd3/7B4d0NxpSsA/tdVYdz5deYcR1U+ZkphdmEFI=" +} +``` + +###### Service Endpoints + +The name of the attribute should follow this format: + +`did/svc/[ServiceName]` + +Example: + +A `DIDAttributeChanged` event for the account `0xf3beac30c498d9e26865f34fcaa57dbb935b0d74` with the name +`did/svc/HubService` and value of the URL `https://hubs.uport.me` hex encoded as +`0x68747470733a2f2f687562732e75706f72742e6d65` generates a service endpoint entry like the following: + +```json +{ + "id": "did:ethr:0xf3beac30c498d9e26865f34fcaa57dbb935b0d74#service-1", + "type": "HubService", + "serviceEndpoint": "https://hubs.uport.me" +} +``` + +#### `id` properties of entries + +With the exception of `#controller` and `#controllerKey`, the `id` properties that appear throughout the DID document +MUST be stable across updates. This means that the same key material will be referenced by the same ID after an update. + +* Attribute or delegate changes that result in `verificationMethod` entries MUST set the `id` + `${did}#delegate-${eventIndex}`. +* Attributes that result in `service` entries MUST set the `id` to `${did}#service-${eventIndex}` + +where `eventIndex` is the index of the event that modifies that section of the DID document. + +**Example** + +* add key => `#delegate-1` is added +* add another key => `#delegate-2` is added +* add delegate => `#delegate-3` is added +* add service => `#service-1` ia added +* revoke first key => `#delegate-1` gets removed from the DID document; `#delegate-2` and `#delegte-3` remain. +* add another delegate => `#delegate-5` is added (earlier revocation is counted as an event) +* first delegate expires => `delegate-3` is removed, `#delegate-5` remains intact + +### Update + +The DID Document may be updated by invoking the relevant smart contract functions as defined by the ERC1056 standard. +This includes changes to the account owner, adding delegates and adding additional attributes. Please find a detailed +description in the [ERC1056 documentation](https://github.com/ethereum/EIPs/issues/1056). + +These functions will trigger the respective Ethereum events which are used to build the DID Document for a given +account as described +in [Enumerating Contract Events to build the DID Document](#Enumerating-Contract-Events-to-build-the-DID-Document). + +Some elements of the DID Document will be revoked automatically when their validity period expires. This includes the +delegates and additional attributes. Please find a detailed description in the +[ERC1056 documentation](https://github.com/ethereum/EIPs/issues/1056). All attribute and delegate functions will trigger +the respective Ethereum events which are used to build the DID Document for a given identifier as described +in [Enumerating Contract Events to build the DID Document](#Enumerating-Contract-Events-to-build-the-DID-Document). + +### Delete (Revoke) + +The `owner` property of the identifier MUST be set to `0x0`. Although, `0x0` is a valid Ethereum address, this will +indicate the account has no owner which is a common approach for invalidation, e.g., tokens. To detect if the `owner` is +the `null` address, one MUST get the logs of the last change to the account and inspect if the `owner` was set to the +null address (`0x0000000000000000000000000000000000000000`). It is impossible to make any other changes to the DID +document after such a change, therefore all preexisting keys and services MUST be considered revoked. + +If the intention is to revoke all the signatures corresponding to the DID, this option MUST be used. + +The DID resolution result for a deactivated DID has the following shape: + +```json +{ + "didDocumentMetadata": { + "deactivated": true + }, + "didResolutionMetadata": { + "contentType": "application/did+ld+json" + }, + "didDocument": { + "@context": "https://www.w3.org/ns/did/v1", + "id": "", + "verificationMethod": [], + "assertionMethod": [], + "authentication": [] + } +} +``` + +## Metadata + +The `resolve` method returns an object with the following properties: `didDocument`, `didDocumentMetadata`, +`didResolutionMetadata`. + +### DID Document Metadata + +When resolving a DID document that has had updates, the latest update MUST be listed in the `didDocumentMetadata`. + +* `versionId` MUST be the block number of the latest update. +* `updated` MUST be the ISO date string of the block time of the latest update (without sub-second resolution). + +Example: + +```json +{ + "didDocumentMetadata": { + "versionId": "12090175", + "updated": "2021-03-22T18:14:29Z" + } +} +``` + +### DID Resolution Metadata + +```json +{ + "didResolutionMetadata": { + "contentType": "application/did+ld+json" + } +} +``` + +## Resolving DID URIs with query parameters. + +### `versionId` query string parameter + +This DID method supports resolving previous versions of the DID document by specifying a `versionId` parameter. + +Example: `did:ethr:0x26bf14321004e770e7a8b080b7a526d8eed8b388?versionId=12090175` + +The `versionId` is the block number at which the DID resolution MUST be performed. +Only ERC1056 events prior to or contained in this block number are to be considered when building the event history. + +If there are any events after that block that mutate the DID, the earliest of them SHOULD be used to populate the +properties of the `didDocumentMetadata`: + +* `nextVersionId` MUST be the block number of the next update to the DID document. +* `nextUpdate` MUST be the ISO date string of the block time of the next update (without sub-second resolution). + +In case the DID has had updates prior to or included in the `versionId` block number, the `updated` and `versionId` +properties of the `didDocumentMetadata` MUST correspond to the latest block prior to the `versionId` query string param. + +Any timestamp comparisons of `validTo` fields of the event history MUST be done against the `versionId` block timestamp. + +Example: +`?versionId=12101682` + +```json +{ + "didDocumentMetadata": { + "versionId": "12090175", + "updated": "2021-03-22T18:14:29Z", + "nextVersionId": "12276565", + "nextUpdate": "2021-04-20T10:48:42Z" + } +} +``` + +#### Security considerations of DID versioning + +Applications MUST take precautions when using versioned DID URIs. +If a key is compromised and revoked then it can still be used to issue signatures on behalf of the "older" DID URI. +The use of versioned DID URIs is only recommended in some limited situations where the timestamp of signatures can also +be verified, where malicious signatures can be easily revoked, and where applications can afford to check for these +explicit revocations of either keys or signatures. +Wherever versioned DIDs are in use, it SHOULD be made obvious to users that they are dealing with potentially revoked +data. + +### `initial-state` query string parameter + +TBD + +## Reference Implementations + +The code at [https://github.com/decentralized-identity/ethr-did-resolver]() is intended to present a reference +implementation of this DID method. + +## References + +**[1]** + +**[2]** + +**[3]** + +**[4]** diff --git a/examples/README.md b/examples/README.md index ed40b5e..4c0e0c4 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,12 +9,12 @@ delegated blockchain transaction with privacy-preserving audit. ```mermaid flowchart LR subgraph "1. Org Onboarding" - TA[Trust Anchor
did:webs] -->|authorization VP| SS1[Signing Service] - SS1 -->|LegalPersonCredential| LP[Legal Person
did:webs] + TA[Trust Anchor
did:ethr] -->|authorization VP| SS1[Signing Service] + SS1 -->|LegalPersonCredential| LP[Legal Person
did:ethr] end subgraph "2. Employee Onboarding" LP -->|authorization VP
SD-JWT, PII redacted| SS2[Signing Service] - SS2 -->|NaturalPersonCredential| NP[Alice
did:webs] + SS2 -->|NaturalPersonCredential| NP[Alice
did:ethr] NP -.->|memberOf| LP end subgraph "3. Consent" @@ -65,21 +65,21 @@ Anchor's DID document. See [`trust-anchor-credential.json`](trust-anchor-credent ## Actors and Identities -Every actor has a `did:webs` identity (KERI-backed, long-lived). Users also have +Every actor has a `did:ethr` identity (ERC-1056-backed, long-lived). Users also have a `did:jwk` wallet key (P-256) in the Altme wallet. When a user requests a credential, the authorizing party presents a VP to the Signing Service. Harbour -then creates the `did:webs` identifier and embeds **the same P-256 public key** -from the wallet into the new `did:webs` DID document. +then creates the `did:ethr` identifier and embeds **the same P-256 public key** +from the wallet into the new `did:ethr` DID document. -| Actor | Role | Identity (`did:webs`) | DID Document | +| Actor | Role | Identity (`did:ethr`) | DID Document | |-------|------|-----------------------|--------------| -| **Harbour Trust Anchor** | Root of trust, authorizes orgs | `did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo` | [`harbour-trust-anchor.did.json`](did-webs/harbour-trust-anchor.did.json) | -| **Harbour Signing Service** | Issues ALL credentials (`#key-1`), signs delegated txns (`#key-2`) | `did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ` | [`harbour-signing-service.did.json`](did-webs/harbour-signing-service.did.json) | -| **Example Corporation GmbH** | Legal person (organization) | `did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-...:ENro7uf0eP...` | [`legal-person-0aa6d7ea-...did.json`](did-webs/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json) | -| **Alice Smith** | Natural person (employee) | `did:webs:users.altme.example:natural-persons:550e8400-...:EKYGGh-Ft...` | [`natural-person-550e8400-...did.json`](did-webs/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json) | -| **ENVITED Marketplace** | Data marketplace (external) | `did:web:dataspace.envited.io` | — | +| **Harbour Trust Anchor** | Root of trust, authorizes orgs | `did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3` | [`harbour-trust-anchor.did.json`](did-ethr/harbour-trust-anchor.did.json) | +| **Harbour Signing Service** | Issues ALL credentials (`#key-1`), signs delegated txns (`#key-2`) | `did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697` | [`harbour-signing-service.did.json`](did-ethr/harbour-signing-service.did.json) | +| **Example Corporation GmbH** | Legal person (organization) | `did:ethr:0x14a34:0xf7ef...dab` | [`legal-person-0aa6d7ea-...did.json`](did-ethr/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json) | +| **Alice Smith** | Natural person (employee) | `did:ethr:0x14a34:0x26e4...16c9` | [`natural-person-550e8400-...did.json`](did-ethr/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json) | +| **ENVITED Marketplace** | Data marketplace (external) | `did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c` | — | -> **Privacy note**: All `did:webs` identifiers use UUID path segments — never +> **Privacy note**: All `did:ethr` identifiers use UUID path segments — never > real names or organization names. This prevents DID IRIs from leaking identity > information at the public layer. @@ -105,15 +105,15 @@ with this VP as evidence. ```mermaid sequenceDiagram - participant TA as Trust Anchor
(did:webs) - participant SS as Signing Service
(did:webs) - participant DW as did:webs Registry + participant TA as Trust Anchor
(did:ethr) + participant SS as Signing Service
(did:ethr) + participant DW as did:ethr Registry TA->>SS: Authorize org credential issuance TA->>TA: Create VP with self-signed
LegalPersonCredential TA->>SS: Authorization VP (Trust Anchor's credential inside) SS->>SS: Verify VP + Trust Anchor credential - SS->>DW: Create did:webs for legal person + SS->>DW: Create did:ethr for legal person SS->>SS: Sign LegalPersonCredential
(evidence = Trust Anchor's VP) SS->>DW: Deliver LegalPersonCredential ``` @@ -131,7 +131,7 @@ self-signed LegalPersonCredential, establishing the chain of trust. | [`signed/legal-person-credential.jwt`](signed/legal-person-credential.jwt) | Signed credential (VC-JOSE-COSE wire format) | | [`signed/legal-person-credential.decoded.json`](signed/legal-person-credential.decoded.json) | Decoded JWT (header + payload) | | [`signed/legal-person-credential.evidence-vp.jwt`](signed/legal-person-credential.evidence-vp.jwt) | Evidence VP (Trust Anchor authorization) | -| [`did-webs/legal-person-0aa6d7ea-...did.json`](did-webs/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json) | Legal person DID document | +| [`did-ethr/legal-person-0aa6d7ea-...did.json`](did-ethr/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json) | Legal person DID document | ### Code @@ -159,22 +159,22 @@ as evidence. ```mermaid sequenceDiagram - participant ORG as Organization
(did:webs) - participant SS as Signing Service
(did:webs) - participant DW as did:webs Registry + participant ORG as Organization
(did:ethr) + participant SS as Signing Service
(did:ethr) + participant DW as did:ethr Registry ORG->>SS: Authorize employee credential issuance ORG->>ORG: Create VP with LegalPersonCredential
(SD-JWT, PII redacted) ORG->>SS: Authorization VP (org credential inside) SS->>SS: Verify VP + org credential
(name disclosed, PII redacted) - SS->>DW: Create did:webs for natural person + SS->>DW: Create did:ethr for natural person SS->>SS: Sign NaturalPersonCredential
(evidence = org's VP, memberOf link) SS->>DW: Deliver NaturalPersonCredential ``` **Chain of trust**: The Trust Anchor authorized the org (Step 1), the org authorizes the employee (Step 2), and the Signing Service issues both credentials. -The `memberOf` field references the legal person's opaque `did:webs` identifier +The `memberOf` field references the legal person's opaque `did:ethr` identifier (UUID-based, no company name). A verifier can resolve this DID to confirm organizational affiliation without the credential itself leaking PII. @@ -190,7 +190,7 @@ organizational affiliation without the credential itself leaking PII. | [`signed/natural-person-credential.jwt`](signed/natural-person-credential.jwt) | Signed credential (VC-JOSE-COSE wire format) | | [`signed/natural-person-credential.decoded.json`](signed/natural-person-credential.decoded.json) | Decoded JWT (header + payload) | | [`signed/natural-person-credential.evidence-vp.jwt`](signed/natural-person-credential.evidence-vp.jwt) | Evidence VP (org authorization) | -| [`did-webs/natural-person-550e8400-...did.json`](did-webs/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json) | Alice's DID document | +| [`did-ethr/natural-person-550e8400-...did.json`](did-ethr/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json) | Alice's DID document | ### Code @@ -199,7 +199,7 @@ organizational affiliation without the credential itself leaking PII. from credentials.claim_mapping import vc_to_sd_jwt_claims, MAPPINGS mapping = MAPPINGS["harbour:NaturalPersonCredential"] claims, disclosable = vc_to_sd_jwt_claims(credential, mapping) -# claims: {"iss": ..., "vct": ..., "givenName": "Alice", "memberOf": "did:webs:..."} +# claims: {"iss": ..., "vct": ..., "givenName": "Alice", "memberOf": "did:ethr:0x14a34:0x..."} # disclosable: ["givenName", "familyName", "email", "memberOf"] ``` @@ -220,7 +220,7 @@ purchase. Alice's wallet creates an **SD-JWT VP** with: ```mermaid sequenceDiagram participant A as Alice's Wallet
(did:jwk) - participant SS as Signing Service
(did:webs) + participant SS as Signing Service
(did:ethr) participant MP as ENVITED Marketplace A->>SS: "Buy asset X for 100 ENVITED" @@ -266,7 +266,7 @@ tx = TransactionData.create( "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", - "marketplace": "did:web:dataspace.envited.io", + "marketplace": "did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c", }, credential_ids=["harbour_natural_person"], ) @@ -285,10 +285,10 @@ sd_jwt_vp = issue_sd_jwt_vp( evidence=[{ "type": "DelegatedSignatureEvidence", "transaction_data": tx.to_dict(), - "delegatedTo": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "delegatedTo": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", }], nonce=tx.nonce, - audience="did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + audience="did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", ) ``` @@ -310,7 +310,7 @@ proof embedded as evidence. ```mermaid sequenceDiagram - participant SS as Signing Service
(did:webs) + participant SS as Signing Service
(did:ethr) participant BC as Blockchain participant A as Alice's Wallet @@ -352,7 +352,7 @@ result = verify_sd_jwt_vp( issuer_public_key, holder_public_key, expected_nonce="da9b1009", - expected_audience="did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + expected_audience="did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", ) # Python — sign receipt credential @@ -397,14 +397,14 @@ For each credential, the signer produces: | `.evidence-vp.jwt` | Evidence VP as signed JWS (if credential has evidence) | | `.evidence-vp.decoded.json` | Decoded evidence VP | -### DID documents (`did-webs/`) +### DID documents (`did-ethr/`) | File | Actor | Method | |------|-------|--------| -| [`harbour-trust-anchor.did.json`](did-webs/harbour-trust-anchor.did.json) | Harbour Trust Anchor | `did:webs` | -| [`harbour-signing-service.did.json`](did-webs/harbour-signing-service.did.json) | Harbour Signing Service | `did:webs` | -| [`legal-person-0aa6d7ea-...did.json`](did-webs/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json) | Example Corporation GmbH | `did:webs` | -| [`natural-person-550e8400-...did.json`](did-webs/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json) | Alice Smith | `did:webs` | +| [`harbour-trust-anchor.did.json`](did-ethr/harbour-trust-anchor.did.json) | Harbour Trust Anchor | `did:ethr` | +| [`harbour-signing-service.did.json`](did-ethr/harbour-signing-service.did.json) | Harbour Signing Service | `did:ethr` | +| [`legal-person-0aa6d7ea-...did.json`](did-ethr/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json) | Example Corporation GmbH | `did:ethr` | +| [`natural-person-550e8400-...did.json`](did-ethr/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json) | Alice Smith | `did:ethr` | ## Regenerating Signed Examples @@ -426,4 +426,4 @@ artifacts to `examples/signed/` and `examples/gaiax/signed/` respectively. - [Evidence types](../docs/guide/evidence.md) — CredentialEvidence + DelegatedSignatureEvidence - [Delegated signing flow](../docs/guide/delegated-signing.md) — Complete OID4VP consent flow - [Delegation challenge spec](../docs/specs/delegation-challenge-encoding.md) — Challenge format + transaction data -- [DID documents](did-webs/README.md) — All example `did:webs` identifiers +- [DID documents](did-ethr/README.md) — All example `did:ethr` identifiers diff --git a/examples/delegated-signing-receipt.json b/examples/delegated-signing-receipt.json index b67a904..ef634dc 100644 --- a/examples/delegated-signing-receipt.json +++ b/examples/delegated-signing-receipt.json @@ -8,17 +8,17 @@ "harbour:DelegatedSigningReceipt" ], "id": "urn:uuid:f7e8d9c0-b1a2-3456-7890-abcdef012345", - "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "validFrom": "2025-06-25T10:00:00Z", "credentialSubject": { - "id": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", + "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", "type": "harbour:TransactionReceipt", "transactionHash": "cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52", "blockchainTxId": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" }, "credentialStatus": [ { - "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry/f7e8d9c0b1a23456", + "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/f7e8d9c0b1a23456", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -44,16 +44,16 @@ "VerifiableCredential", "harbour:NaturalPersonCredential" ], - "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "credentialSubject": { - "id": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", + "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", "type": "harbour:NaturalPerson", - "memberOf": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" + "memberOf": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" } } ] }, - "delegatedTo": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "delegatedTo": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "transaction_data": { "type": "harbour_delegate:data.purchase", "credential_ids": [ @@ -68,7 +68,7 @@ "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", - "marketplace": "did:web:dataspace.envited.io" + "marketplace": "did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c" } }, "challenge": "da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52" diff --git a/examples/did-ethr/README.md b/examples/did-ethr/README.md new file mode 100644 index 0000000..cbb840c --- /dev/null +++ b/examples/did-ethr/README.md @@ -0,0 +1,42 @@ +# did:ethr DID Documents + +Example DID documents for the Harbour identity ecosystem, using `did:ethr` (ERC-1056) +on **Base** (chain ID `84532` / `0x14a34` for testnet). + +## Entities + +| File | Role | DID | +|------|------|-----| +| `harbour-signing-service.did.json` | Signing service (issues credentials) | `did:ethr:0x14a34:0x9c2f...c697` | +| `harbour-trust-anchor.did.json` | Trust anchor (root of trust) | `did:ethr:0x14a34:0xf8ab...38c3` | +| `legal-person-0aa6d7ea-...did.json` | Legal person (participant) | `did:ethr:0x14a34:0xf7ef...dab` | +| `natural-person-550e8400-...did.json` | Natural person (user) | `did:ethr:0x14a34:0x26e4...16c9` | + +## DID Document Structure + +Each document follows the `did:ethr` resolved format: + +- **`@context`** includes `secp256k1recovery-2020/v2` for the controller VM +- **`controller`** points to the smart contract that manages identity ownership +- **`#controller`** verification method: `EcdsaSecp256k1RecoveryMethod2020` with `blockchainAccountId` +- **`#delegate-N`** verification methods: P-256 `JsonWebKey` keys registered as on-chain attributes + +## Controller + +All identities are governed by a smart contract controller: +``` +did:ethr:0x14a34:0xC0FFEEbabe000000000000000000000000000001 +``` +This is a placeholder address — the actual contract will be deployed to Base. + +## Key Management + +P-256 keys (ES256) are the primary signing keys, registered on-chain via `setAttribute()`. +The secp256k1 controller key provides blockchain-native identity ownership. + +## Usage + +These DID documents are referenced by: +- `examples/*.json` — Credential examples (issuer, subject, holder) +- `examples/gaiax/*.json` — Gaia-X specific credential examples +- `tests/` — Test fixtures and assertions diff --git a/examples/did-ethr/harbour-signing-service.did.json b/examples/did-ethr/harbour-signing-service.did.json new file mode 100644 index 0000000..ae98ceb --- /dev/null +++ b/examples/did-ethr/harbour-signing-service.did.json @@ -0,0 +1,52 @@ +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/secp256k1recovery-2020/v2" + ], + "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "controller": "did:ethr:0x14a34:0xC0FFEEbabe000000000000000000000000000001", + "verificationMethod": [ + { + "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#controller", + "type": "EcdsaSecp256k1RecoveryMethod2020", + "controller": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "blockchainAccountId": "eip155:84532:0x9c2f52ea812629d0d35b2786ae26633d03a8c697" + }, + { + "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-1", + "type": "JsonWebKey", + "controller": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "W3OIQrEY5e5WOLMSqo82WIiKnNS3YZmCwazJ5jCReGk", + "y": "D562mZty35hWJ2V6rKQ5N5IJOKpZkVL52ucujzNcMI8" + } + }, + { + "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-2", + "type": "JsonWebKey", + "controller": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "aBzuAd1MlQAeJxCitiz-AqlMgsw1K1L_tuJ8u8hptxY", + "y": "ZRY5BsEos8y7QP4hKstg7uzFjduqq_zZlyFIES6Kagk" + } + } + ], + "authentication": [ + "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#controller", + "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-1", + "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-2" + ], + "assertionMethod": [ + "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#controller", + "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-1", + "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-2" + ], + "capabilityDelegation": [ + "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-1", + "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-2" + ] +} diff --git a/examples/did-ethr/harbour-trust-anchor.did.json b/examples/did-ethr/harbour-trust-anchor.did.json new file mode 100644 index 0000000..a5e20bc --- /dev/null +++ b/examples/did-ethr/harbour-trust-anchor.did.json @@ -0,0 +1,42 @@ +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/secp256k1recovery-2020/v2" + ], + "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "controller": "did:ethr:0x14a34:0xC0FFEEbabe000000000000000000000000000001", + "verificationMethod": [ + { + "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#controller", + "type": "EcdsaSecp256k1RecoveryMethod2020", + "controller": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "blockchainAccountId": "eip155:84532:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3" + }, + { + "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#delegate-1", + "type": "JsonWebKey", + "controller": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "aBzuAd1MlQAeJxCitiz-AqlMgsw1K1L_tuJ8u8hptxY", + "y": "ZRY5BsEos8y7QP4hKstg7uzFjduqq_zZlyFIES6Kagk" + } + } + ], + "authentication": [ + "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#controller", + "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#delegate-1" + ], + "assertionMethod": [ + "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#controller", + "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#delegate-1" + ], + "service": [ + { + "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#linked-credential", + "type": "harbour:LinkedCredentialService", + "serviceEndpoint": "https://reachhaven.com/.well-known/credentials/trust-anchor.jwt" + } + ] +} diff --git a/examples/did-ethr/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json b/examples/did-ethr/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json new file mode 100644 index 0000000..62530b2 --- /dev/null +++ b/examples/did-ethr/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json @@ -0,0 +1,35 @@ +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/secp256k1recovery-2020/v2" + ], + "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", + "controller": "did:ethr:0x14a34:0xC0FFEEbabe000000000000000000000000000001", + "verificationMethod": [ + { + "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab#controller", + "type": "EcdsaSecp256k1RecoveryMethod2020", + "controller": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", + "blockchainAccountId": "eip155:84532:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" + }, + { + "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab#delegate-1", + "type": "JsonWebKey", + "controller": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBL", + "y": "weN12rGDez3FP0HRkGbMEMN6YPf7rMBMcxmvIRbJboo" + } + } + ], + "authentication": [ + "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab#controller", + "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab#delegate-1" + ], + "assertionMethod": [ + "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab#controller", + "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab#delegate-1" + ] +} diff --git a/examples/did-ethr/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json b/examples/did-ethr/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json new file mode 100644 index 0000000..2c445e8 --- /dev/null +++ b/examples/did-ethr/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json @@ -0,0 +1,35 @@ +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/secp256k1recovery-2020/v2" + ], + "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", + "controller": "did:ethr:0x14a34:0xC0FFEEbabe000000000000000000000000000001", + "verificationMethod": [ + { + "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#controller", + "type": "EcdsaSecp256k1RecoveryMethod2020", + "controller": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", + "blockchainAccountId": "eip155:84532:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9" + }, + { + "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#delegate-1", + "type": "JsonWebKey", + "controller": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "6dkU6ZMFK79WwicwJ5rbxE13zSukBY2OoEiVUEjqMEc", + "y": "RnHznyVlrPSMT7irDs15D9wxgMojiSDAQpfFhqTkLRY" + } + } + ], + "authentication": [ + "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#controller", + "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#delegate-1" + ], + "assertionMethod": [ + "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#controller", + "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#delegate-1" + ] +} diff --git a/examples/did-webs/README.md b/examples/did-webs/README.md deleted file mode 100644 index 7bd1205..0000000 --- a/examples/did-webs/README.md +++ /dev/null @@ -1,63 +0,0 @@ -# did:webs Example Documents - -This folder contains static example DID documents for the `did:webs` identifiers -used in `examples/*.json`. - -## Scope and Boundary - -- These files are modeling examples for Harbour v1. -- This repository does **not** resolve `did:webs` identifiers and does **not** - validate `keri.cesr` streams. -- Integrators must host corresponding `did.json` and `keri.cesr` resources in - production according to the `did:webs` method specification. - -## Naming Policy - -All identifiers use **UUID path segments** — never real names, organization names, -or other identifying information in the DID path. This prevents DID IRIs from -leaking identity at the public layer. - -## Credential Issuance Model - -The Harbour Signing Service is the **sole issuer** of all credentials. It uses -two keys in its DID document: - -| Key | Relationship | Purpose | -|-----|-------------|---------| -| `#key-1` | `assertionMethod` | Signs all issued credentials | -| `#key-2` | `capabilityDelegation` | Signs delegated blockchain transactions | - -Authorization is proven via `CredentialEvidence` VPs: - -- **LegalPersonCredential**: Trust Anchor presents VP with its self-signed - LegalPersonCredential (root of trust, publicly resolvable via - `LinkedCredentialService`). -- **NaturalPersonCredential**: Organization presents VP with its - LegalPersonCredential (SD-JWT, sensitive fields redacted). - -## Example Identities - -### Server-side (Harbour infrastructure) - -| Actor | DID | File | -|-------|-----|------| -| Trust Anchor | `did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo` | `harbour-trust-anchor.did.json` | -| Signing Service | `did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ` | `harbour-signing-service.did.json` | - -### User-side (wallet-registered) - -| Actor | DID | Wallet (`did:jwk`) | File | -|-------|-----|--------------------|------| -| Legal person | `did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe` | `did:jwk:eyJ...vbyJ9` | `legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json` | -| Natural person | `did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP` | `did:jwk:eyJ...TLRY` | `natural-person-550e8400-e29b-41d4-a716-446655440000.did.json` | - -## Trust Anchor Self-Signed Credential - -The Trust Anchor holds a self-signed `LegalPersonCredential` where -`issuer == credentialSubject.id`. This is analogous to a root CA certificate. -It is linked from the Trust Anchor's DID document via a -`harbour:LinkedCredentialService` service endpoint, making it publicly resolvable. - -See [`../trust-anchor-credential.json`](../trust-anchor-credential.json). - -See [`../README.md`](../README.md) for the complete user journey. diff --git a/examples/did-webs/harbour-signing-service.did.json b/examples/did-webs/harbour-signing-service.did.json deleted file mode 100644 index 6f4d5d5..0000000 --- a/examples/did-webs/harbour-signing-service.did.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/jwk/v1" - ], - "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", - "controller": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", - "verificationMethod": [ - { - "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ#key-1", - "type": "JsonWebKey2020", - "controller": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", - "publicKeyJwk": { - "kty": "EC", - "crv": "P-256", - "x": "W3OIQrEY5e5WOLMSqo82WIiKnNS3YZmCwazJ5jCReGk", - "y": "D562mZty35hWJ2V6rKQ5N5IJOKpZkVL52ucujzNcMI8" - } - }, - { - "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ#key-2", - "type": "JsonWebKey2020", - "controller": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", - "publicKeyJwk": { - "kty": "EC", - "crv": "P-256", - "x": "aBzuAd1MlQAeJxCitiz-AqlMgsw1K1L_tuJ8u8hptxY", - "y": "ZRY5BsEos8y7QP4hKstg7uzFjduqq_zZlyFIES6Kagk" - } - } - ], - "authentication": [ - "#key-1", - "#key-2" - ], - "assertionMethod": [ - "#key-1" - ], - "capabilityDelegation": [ - "#key-2" - ] -} diff --git a/examples/did-webs/harbour-trust-anchor.did.json b/examples/did-webs/harbour-trust-anchor.did.json deleted file mode 100644 index dd529d1..0000000 --- a/examples/did-webs/harbour-trust-anchor.did.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/jwk/v1" - ], - "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", - "controller": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", - "verificationMethod": [ - { - "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo#key-1", - "type": "JsonWebKey2020", - "controller": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", - "publicKeyJwk": { - "kty": "EC", - "crv": "P-256", - "x": "aBzuAd1MlQAeJxCitiz-AqlMgsw1K1L_tuJ8u8hptxY", - "y": "ZRY5BsEos8y7QP4hKstg7uzFjduqq_zZlyFIES6Kagk" - } - } - ], - "authentication": [ - "#key-1" - ], - "assertionMethod": [ - "#key-1" - ], - "service": [ - { - "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo#linked-credential", - "type": "harbour:LinkedCredentialService", - "serviceEndpoint": "https://reachhaven.com/.well-known/credentials/trust-anchor.jwt" - } - ] -} diff --git a/examples/did-webs/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json b/examples/did-webs/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json deleted file mode 100644 index f2e26c3..0000000 --- a/examples/did-webs/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/jwk/v1" - ], - "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", - "controller": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", - "verificationMethod": [ - { - "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe#key-1", - "type": "JsonWebKey2020", - "controller": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", - "publicKeyJwk": { - "kty": "EC", - "crv": "P-256", - "x": "ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBL", - "y": "weN12rGDez3FP0HRkGbMEMN6YPf7rMBMcxmvIRbJboo" - } - } - ], - "authentication": [ - "#key-1" - ], - "assertionMethod": [ - "#key-1" - ] -} diff --git a/examples/did-webs/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json b/examples/did-webs/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json deleted file mode 100644 index 04c8957..0000000 --- a/examples/did-webs/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/jwk/v1" - ], - "id": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", - "controller": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", - "verificationMethod": [ - { - "id": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP#key-1", - "type": "JsonWebKey2020", - "controller": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", - "publicKeyJwk": { - "kty": "EC", - "crv": "P-256", - "x": "6dkU6ZMFK79WwicwJ5rbxE13zSukBY2OoEiVUEjqMEc", - "y": "RnHznyVlrPSMT7irDs15D9wxgMojiSDAQpfFhqTkLRY" - } - } - ], - "authentication": [ - "#key-1" - ], - "assertionMethod": [ - "#key-1" - ] -} diff --git a/examples/gaiax/legal-person-credential-bmw.json b/examples/gaiax/legal-person-credential-bmw.json index d976298..5e7322c 100644 --- a/examples/gaiax/legal-person-credential-bmw.json +++ b/examples/gaiax/legal-person-credential-bmw.json @@ -9,11 +9,11 @@ "harbour:LegalPersonCredential" ], "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "validFrom": "2025-01-15T00:00:00Z", "validUntil": "2030-01-15T00:00:00Z", "credentialSubject": { - "id": "did:web:did.ascs.digital:participants:bmw", + "id": "did:ethr:0x14a34:0x9d273DCaC2f6367968d61caf69A7E3177fd81048", "type": "harbour:LegalPerson", "gxParticipant": { "type": "gx:LegalPerson", @@ -44,7 +44,7 @@ }, "credentialStatus": [ { - "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry/a1b2c3d4e5f67890", + "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/a1b2c3d4e5f67890", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -55,7 +55,7 @@ "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "holder": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "nonce": "c7821a0b ISSUE_PAYLOAD 9e5f3a2d1b7c4e6f8a0d2c4b6e8f0a2c4d6e8b0f2a4c6d8e0b2a4c6d8e0f2a" } } diff --git a/examples/gaiax/legal-person-credential.json b/examples/gaiax/legal-person-credential.json index 5f59e3d..a352962 100644 --- a/examples/gaiax/legal-person-credential.json +++ b/examples/gaiax/legal-person-credential.json @@ -9,11 +9,11 @@ "harbour:LegalPersonCredential" ], "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "validFrom": "2024-01-15T00:00:00Z", "validUntil": "2025-01-15T00:00:00Z", "credentialSubject": { - "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", "type": "harbour:LegalPerson", "gxParticipant": { "type": "gx:LegalPerson", @@ -38,7 +38,7 @@ }, "credentialStatus": [ { - "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry/a1b2c3d4e5f67890", + "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/a1b2c3d4e5f67890", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -49,7 +49,7 @@ "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "holder": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "verifiableCredential": [ { "@context": [ @@ -61,9 +61,9 @@ "VerifiableCredential", "harbour:LegalPersonCredential" ], - "issuer": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "credentialSubject": { - "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "type": "harbour:LegalPerson", "gxParticipant": { "type": "gx:LegalPerson", diff --git a/examples/gaiax/natural-person-credential-andreas.json b/examples/gaiax/natural-person-credential-andreas.json index 9bc6a22..6c2732f 100644 --- a/examples/gaiax/natural-person-credential-andreas.json +++ b/examples/gaiax/natural-person-credential-andreas.json @@ -9,17 +9,17 @@ "harbour:NaturalPersonCredential" ], "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f23456789012", - "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "validFrom": "2025-01-15T00:00:00Z", "validUntil": "2030-01-15T00:00:00Z", "credentialSubject": { - "id": "did:web:did.ascs.digital:users:21c7c8bc-6860-490b-8ec7-219c89d93e2c", + "id": "did:ethr:0x14a34:0xb2F78332cF29Bd4dBB04Dea2EF59439F43F0b39a", "type": "harbour:NaturalPerson", "name": "Andreas Admin", "schema:givenName": "Andreas", "schema:familyName": "Admin", "schema:email": "andreas.admin@bmw.com", - "memberOf": "did:web:did.ascs.digital:participants:bmw", + "memberOf": "did:ethr:0x14a34:0x9d273DCaC2f6367968d61caf69A7E3177fd81048", "gxParticipant": { "type": "gx:Participant", "schema:name": "Andreas Admin" @@ -27,7 +27,7 @@ }, "credentialStatus": [ { - "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry/b2c3d4e5f6a78901", + "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/b2c3d4e5f6a78901", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -38,7 +38,7 @@ "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:web:did.ascs.digital:participants:bmw", + "holder": "did:ethr:0x14a34:0x9d273DCaC2f6367968d61caf69A7E3177fd81048", "verifiableCredential": [ { "@context": [ @@ -50,9 +50,9 @@ "VerifiableCredential", "harbour:LegalPersonCredential" ], - "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "credentialSubject": { - "id": "did:web:did.ascs.digital:participants:bmw", + "id": "did:ethr:0x14a34:0x9d273DCaC2f6367968d61caf69A7E3177fd81048", "type": "harbour:LegalPerson", "gxParticipant": { "type": "gx:LegalPerson", diff --git a/examples/gaiax/natural-person-credential-max.json b/examples/gaiax/natural-person-credential-max.json index f9fc208..9a94b4c 100644 --- a/examples/gaiax/natural-person-credential-max.json +++ b/examples/gaiax/natural-person-credential-max.json @@ -9,17 +9,17 @@ "harbour:NaturalPersonCredential" ], "id": "urn:uuid:c3d4e5f6-a7b8-9012-cdef-345678901234", - "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "validFrom": "2025-01-15T00:00:00Z", "validUntil": "2030-01-15T00:00:00Z", "credentialSubject": { - "id": "did:web:did.ascs.digital:users:44b982bb-ae61-4f6f-899f-a0982aaf367e", + "id": "did:ethr:0x14a34:0x0f4Dc6903A4B92C6563DD3551421ebb7ACa7d4fC", "type": "harbour:NaturalPerson", "name": "Max Mustermann", "schema:givenName": "Max", "schema:familyName": "Mustermann", "schema:email": "max.mustermann@bmw.com", - "memberOf": "did:web:did.ascs.digital:participants:bmw", + "memberOf": "did:ethr:0x14a34:0x9d273DCaC2f6367968d61caf69A7E3177fd81048", "gxParticipant": { "type": "gx:Participant", "schema:name": "Max Mustermann" @@ -27,7 +27,7 @@ }, "credentialStatus": [ { - "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry/c3d4e5f6a7b89012", + "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/c3d4e5f6a7b89012", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -38,7 +38,7 @@ "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:web:did.ascs.digital:participants:bmw", + "holder": "did:ethr:0x14a34:0x9d273DCaC2f6367968d61caf69A7E3177fd81048", "verifiableCredential": [ { "@context": [ @@ -50,9 +50,9 @@ "VerifiableCredential", "harbour:LegalPersonCredential" ], - "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "credentialSubject": { - "id": "did:web:did.ascs.digital:participants:bmw", + "id": "did:ethr:0x14a34:0x9d273DCaC2f6367968d61caf69A7E3177fd81048", "type": "harbour:LegalPerson", "gxParticipant": { "type": "gx:LegalPerson", diff --git a/examples/gaiax/natural-person-credential.json b/examples/gaiax/natural-person-credential.json index 85e89a1..3f653c6 100644 --- a/examples/gaiax/natural-person-credential.json +++ b/examples/gaiax/natural-person-credential.json @@ -9,16 +9,16 @@ "harbour:NaturalPersonCredential" ], "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f23456789012", - "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "validFrom": "2024-01-15T00:00:00Z", "validUntil": "2025-01-15T00:00:00Z", "credentialSubject": { - "id": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", + "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", "type": "harbour:NaturalPerson", "schema:givenName": "Alice", "schema:familyName": "Smith", "schema:email": "alice.smith@example.com", - "memberOf": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "memberOf": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", "gxParticipant": { "type": "gx:Participant", "schema:name": "Alice Smith" @@ -26,7 +26,7 @@ }, "credentialStatus": [ { - "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry/b2c3d4e5f6a78901", + "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/b2c3d4e5f6a78901", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -37,7 +37,7 @@ "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "holder": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", "verifiableCredential": [ { "@context": [ @@ -49,9 +49,9 @@ "VerifiableCredential", "harbour:LegalPersonCredential" ], - "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "credentialSubject": { - "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", "type": "harbour:LegalPerson", "gxParticipant": { "type": "gx:LegalPerson", diff --git a/examples/legal-person-credential.json b/examples/legal-person-credential.json index 8642d1f..9f8a9f2 100644 --- a/examples/legal-person-credential.json +++ b/examples/legal-person-credential.json @@ -8,17 +8,17 @@ "harbour:LegalPersonCredential" ], "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "validFrom": "2024-01-15T00:00:00Z", "validUntil": "2025-01-15T00:00:00Z", "credentialSubject": { - "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", "type": "harbour:LegalPerson", "name": "Example Corporation GmbH" }, "credentialStatus": [ { - "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry/a1b2c3d4e5f67890", + "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/a1b2c3d4e5f67890", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -29,7 +29,7 @@ "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "holder": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "verifiableCredential": [ { "@context": [ @@ -40,9 +40,9 @@ "VerifiableCredential", "harbour:LegalPersonCredential" ], - "issuer": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "credentialSubject": { - "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "type": "harbour:LegalPerson", "name": "ReachHaven GmbH" } diff --git a/examples/natural-person-credential.json b/examples/natural-person-credential.json index 284e9ef..e776414 100644 --- a/examples/natural-person-credential.json +++ b/examples/natural-person-credential.json @@ -8,21 +8,21 @@ "harbour:NaturalPersonCredential" ], "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f23456789012", - "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "validFrom": "2024-01-15T00:00:00Z", "validUntil": "2025-01-15T00:00:00Z", "credentialSubject": { - "id": "did:webs:users.altme.example:natural-persons:550e8400-e29b-41d4-a716-446655440000:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9OxmP", + "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", "type": "harbour:NaturalPerson", "name": "Alice Smith", "schema:givenName": "Alice", "schema:familyName": "Smith", "schema:email": "alice.smith@example.com", - "memberOf": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" + "memberOf": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" }, "credentialStatus": [ { - "id": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ:services:revocation-registry/b2c3d4e5f6a78901", + "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/b2c3d4e5f6a78901", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -33,7 +33,7 @@ "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "holder": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", "verifiableCredential": [ { "@context": [ @@ -44,9 +44,9 @@ "VerifiableCredential", "harbour:LegalPersonCredential" ], - "issuer": "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "credentialSubject": { - "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", "type": "harbour:LegalPerson", "name": "Example Corporation GmbH" } diff --git a/examples/trust-anchor-credential.json b/examples/trust-anchor-credential.json index 4a50f89..79f86b2 100644 --- a/examples/trust-anchor-credential.json +++ b/examples/trust-anchor-credential.json @@ -8,16 +8,16 @@ "harbour:LegalPersonCredential" ], "id": "urn:uuid:c4d5e6f7-a8b9-0123-cdef-456789abcdef", - "issuer": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { - "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "type": "harbour:LegalPerson", "name": "ReachHaven GmbH" }, "credentialStatus": [ { - "id": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo:services:revocation-registry/c4d5e6f7a8b90123", + "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3:services:revocation-registry/c4d5e6f7a8b90123", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } diff --git a/linkml/harbour-core-credential.yaml b/linkml/harbour-core-credential.yaml index 3394175..1f875df 100644 --- a/linkml/harbour-core-credential.yaml +++ b/linkml/harbour-core-credential.yaml @@ -43,7 +43,7 @@ slots: # --- DID Slots --- # Spec: DID-Core §4.2 — controller is a URI or set of URIs. - # In practice always a DID (did:web:..., did:key:..., etc.). + # In practice always a DID (did:ethr:..., did:key:..., etc.). controller: slot_uri: https://www.w3.org/ns/did#controller range: uri diff --git a/src/python/harbour/generate_artifacts.py b/src/python/harbour/generate_artifacts.py index 50eeedf..79255d6 100644 --- a/src/python/harbour/generate_artifacts.py +++ b/src/python/harbour/generate_artifacts.py @@ -9,6 +9,7 @@ (accepting both IRIs from JSON-LD and literal strings from plain JSON). """ +import json from pathlib import Path from linkml.generators.jsonldcontextgen import ContextGenerator @@ -74,9 +75,18 @@ def main() -> None: ) ctx_gen = ContextGenerator(schema) - (out_dir / f"{domain}.context.jsonld").write_text( - ctx_gen.serialize(), encoding="utf-8" - ) + ctx_text = ctx_gen.serialize() + + # Ensure "type": "@type" is present in the generated context. + # See harbour-core-credential.yaml §slots comment for rationale. + ctx_data = json.loads(ctx_text) + ctx_obj = ctx_data.get("@context", {}) + if isinstance(ctx_obj, dict) and "type" not in ctx_obj: + ctx_obj["type"] = "@type" + ctx_data["@context"] = ctx_obj + ctx_text = json.dumps(ctx_data, indent=3, ensure_ascii=False) + + (out_dir / f"{domain}.context.jsonld").write_text(ctx_text, encoding="utf-8") print(f"\nDone: {ARTIFACTS_DIR}/") diff --git a/src/python/harbour/kb_jwt.py b/src/python/harbour/kb_jwt.py index 41f0945..f6d557a 100644 --- a/src/python/harbour/kb_jwt.py +++ b/src/python/harbour/kb_jwt.py @@ -200,8 +200,8 @@ def main(): formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: - python -m harbour.kb_jwt create --sd-jwt token.txt --key key.jwk --nonce abc --audience did:web:verifier - python -m harbour.kb_jwt verify --sd-jwt token.txt --public-key key.jwk --nonce abc --audience did:web:verifier + python -m harbour.kb_jwt create --sd-jwt token.txt --key key.jwk --nonce abc --audience did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0 + python -m harbour.kb_jwt verify --sd-jwt token.txt --public-key key.jwk --nonce abc --audience did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0 """, ) diff --git a/src/python/harbour/sd_jwt_vp.py b/src/python/harbour/sd_jwt_vp.py index 0ba4c9e..c9a1aa5 100644 --- a/src/python/harbour/sd_jwt_vp.py +++ b/src/python/harbour/sd_jwt_vp.py @@ -572,7 +572,7 @@ def main(): Examples: # Issue an SD-JWT VP with selective disclosure python -m harbour.sd_jwt_vp issue --sd-jwt-vc vc.txt --key holder-key.jwk \\ - --disclosures memberOf --nonce abc123 --audience did:web:verifier.example.com + --disclosures memberOf --nonce abc123 --audience did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0 # Issue with evidence (DelegatedSignatureEvidence) python -m harbour.sd_jwt_vp issue --sd-jwt-vc vc.txt --key holder-key.jwk \\ diff --git a/tests/fixtures/canonicalization-vectors.json b/tests/fixtures/canonicalization-vectors.json index 878d997..193bd4b 100644 --- a/tests/fixtures/canonicalization-vectors.json +++ b/tests/fixtures/canonicalization-vectors.json @@ -17,14 +17,14 @@ "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", "price": "100", "currency": "ENVITED", - "marketplace": "did:web:dataspace.envited.io" + "marketplace": "did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c" } }, - "canonical_json": "{\"credential_ids\":[\"harbour_natural_person\"],\"iat\":1771934400,\"nonce\":\"da9b1009\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"asset_id\":\"urn:uuid:550e8400-e29b-41d4-a716-446655440000\",\"currency\":\"ENVITED\",\"marketplace\":\"did:web:dataspace.envited.io\",\"price\":\"100\"},\"type\":\"harbour_delegate:data.purchase\"}", - "sha256_hash": "cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52", - "challenge": "da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52", - "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJoYXJib3VyX25hdHVyYWxfcGVyc29uIl0sImlhdCI6MTc3MTkzNDQwMCwibm9uY2UiOiJkYTliMTAwOSIsInRyYW5zYWN0aW9uX2RhdGFfaGFzaGVzX2FsZyI6WyJzaGEtMjU2Il0sInR4biI6eyJhc3NldF9pZCI6InVybjp1dWlkOjU1MGU4NDAwLWUyOWItNDFkNC1hNzE2LTQ0NjY1NTQ0MDAwMCIsImN1cnJlbmN5IjoiRU5WSVRFRCIsIm1hcmtldHBsYWNlIjoiZGlkOndlYjpkYXRhc3BhY2UuZW52aXRlZC5pbyIsInByaWNlIjoiMTAwIn0sInR5cGUiOiJoYXJib3VyX2RlbGVnYXRlOmRhdGEucHVyY2hhc2UifQ", - "transaction_data_param_hash": "b3zwXdmQYj3kdUJLWeHIh_hXDrSucGSTXr4wvob5hqo" + "canonical_json": "{\"credential_ids\":[\"harbour_natural_person\"],\"iat\":1771934400,\"nonce\":\"da9b1009\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"asset_id\":\"urn:uuid:550e8400-e29b-41d4-a716-446655440000\",\"currency\":\"ENVITED\",\"marketplace\":\"did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c\",\"price\":\"100\"},\"type\":\"harbour_delegate:data.purchase\"}", + "sha256_hash": "c8f13987d6d597a8124837ee456deef6b3f132cbb373e8daedf0e00afd2120d1", + "challenge": "da9b1009 HARBOUR_DELEGATE c8f13987d6d597a8124837ee456deef6b3f132cbb373e8daedf0e00afd2120d1", + "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJoYXJib3VyX25hdHVyYWxfcGVyc29uIl0sImlhdCI6MTc3MTkzNDQwMCwibm9uY2UiOiJkYTliMTAwOSIsInRyYW5zYWN0aW9uX2RhdGFfaGFzaGVzX2FsZyI6WyJzaGEtMjU2Il0sInR4biI6eyJhc3NldF9pZCI6InVybjp1dWlkOjU1MGU4NDAwLWUyOWItNDFkNC1hNzE2LTQ0NjY1NTQ0MDAwMCIsImN1cnJlbmN5IjoiRU5WSVRFRCIsIm1hcmtldHBsYWNlIjoiZGlkOmV0aHI6MHgxNGEzNDoweDg5ZmU1ZTdmNTA2ZDk5MmY3NmJjYmEzMDk3NzNjMGVlM2VlNjAzOWMiLCJwcmljZSI6IjEwMCJ9LCJ0eXBlIjoiaGFyYm91cl9kZWxlZ2F0ZTpkYXRhLnB1cmNoYXNlIn0", + "transaction_data_param_hash": "ZZ9AEyLSxEiIESLwaWBwgQFpTKc_BCgIJooVa4XUS4A" }, { "name": "contract.sign \u2014 with optional exp and description", @@ -43,16 +43,16 @@ "txn": { "document_hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "parties": [ - "did:web:alice.example", - "did:web:bob.example" + "did:ethr:0x14a34:0x9b280b503a94d1e43ebd8ee2549236c00748dace", + "did:ethr:0x14a34:0x87fdc0cc3b127f964d7651b0d55362663104b892" ] } }, - "canonical_json": "{\"credential_ids\":[\"org_credential\"],\"description\":\"Sign partnership agreement\",\"exp\":1771935300,\"iat\":1771934400,\"nonce\":\"ab12cd34\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"document_hash\":\"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"parties\":[\"did:web:alice.example\",\"did:web:bob.example\"]},\"type\":\"harbour_delegate:contract.sign\"}", - "sha256_hash": "0863ac13bc5f15c7dfcdee71b8beea1aead4b822d0a7c03154405da4f192af08", - "challenge": "ab12cd34 HARBOUR_DELEGATE 0863ac13bc5f15c7dfcdee71b8beea1aead4b822d0a7c03154405da4f192af08", - "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJvcmdfY3JlZGVudGlhbCJdLCJkZXNjcmlwdGlvbiI6IlNpZ24gcGFydG5lcnNoaXAgYWdyZWVtZW50IiwiZXhwIjoxNzcxOTM1MzAwLCJpYXQiOjE3NzE5MzQ0MDAsIm5vbmNlIjoiYWIxMmNkMzQiLCJ0cmFuc2FjdGlvbl9kYXRhX2hhc2hlc19hbGciOlsic2hhLTI1NiJdLCJ0eG4iOnsiZG9jdW1lbnRfaGFzaCI6InNoYTI1NjplM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1IiwicGFydGllcyI6WyJkaWQ6d2ViOmFsaWNlLmV4YW1wbGUiLCJkaWQ6d2ViOmJvYi5leGFtcGxlIl19LCJ0eXBlIjoiaGFyYm91cl9kZWxlZ2F0ZTpjb250cmFjdC5zaWduIn0", - "transaction_data_param_hash": "Sf_HN8fsqdnrWYq6XlXjmUrUpuASJi2F65ZaPMx6uTk" + "canonical_json": "{\"credential_ids\":[\"org_credential\"],\"description\":\"Sign partnership agreement\",\"exp\":1771935300,\"iat\":1771934400,\"nonce\":\"ab12cd34\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"document_hash\":\"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"parties\":[\"did:ethr:0x14a34:0x9b280b503a94d1e43ebd8ee2549236c00748dace\",\"did:ethr:0x14a34:0x87fdc0cc3b127f964d7651b0d55362663104b892\"]},\"type\":\"harbour_delegate:contract.sign\"}", + "sha256_hash": "d5b99dc48015407f56244eaac0912446060daefee19eadf7183ba41de71fd9cb", + "challenge": "ab12cd34 HARBOUR_DELEGATE d5b99dc48015407f56244eaac0912446060daefee19eadf7183ba41de71fd9cb", + "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJvcmdfY3JlZGVudGlhbCJdLCJkZXNjcmlwdGlvbiI6IlNpZ24gcGFydG5lcnNoaXAgYWdyZWVtZW50IiwiZXhwIjoxNzcxOTM1MzAwLCJpYXQiOjE3NzE5MzQ0MDAsIm5vbmNlIjoiYWIxMmNkMzQiLCJ0cmFuc2FjdGlvbl9kYXRhX2hhc2hlc19hbGciOlsic2hhLTI1NiJdLCJ0eG4iOnsiZG9jdW1lbnRfaGFzaCI6InNoYTI1NjplM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1IiwicGFydGllcyI6WyJkaWQ6ZXRocjoweDE0YTM0OjB4OWIyODBiNTAzYTk0ZDFlNDNlYmQ4ZWUyNTQ5MjM2YzAwNzQ4ZGFjZSIsImRpZDpldGhyOjB4MTRhMzQ6MHg4N2ZkYzBjYzNiMTI3Zjk2NGQ3NjUxYjBkNTUzNjI2NjMxMDRiODkyIl19LCJ0eXBlIjoiaGFyYm91cl9kZWxlZ2F0ZTpjb250cmFjdC5zaWduIn0", + "transaction_data_param_hash": "a3_WNgJY8-zqosLSwrlgqS-w70WnnX0FOsbcFh5fgfY" }, { "name": "blockchain.transfer \u2014 nested txn verifies recursive sort", @@ -105,18 +105,18 @@ "gasless": false }, "approvers": [ - "did:web:bmw.example", - "did:web:supplier.example" + "did:ethr:0x14a34:0x081d85aa2de20b04cf2e2114b56d7a3c025f69c1", + "did:ethr:0x14a34:0x59404f9182101ca5c3e4b3c5dab9fb25bfa0b9ba" ] }, "value": "0" } }, - "canonical_json": "{\"credential_ids\":[\"wallet_cred\",\"org_cred\"],\"description\":\"Execute settlement\",\"iat\":1771934400,\"nonce\":\"91af4c2e\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"chain\":\"eip155:1\",\"contract\":\"0x1234567890abcdef1234567890abcdef12345678\",\"method\":\"settle\",\"params\":{\"amount\":\"4200000000000000000\",\"approvers\":[\"did:web:bmw.example\",\"did:web:supplier.example\"],\"flags\":{\"gasless\":false,\"urgent\":true},\"recipient\":\"0xAbCdEf1234567890aBCDef1234567890abCDef12\"},\"value\":\"0\"},\"type\":\"harbour_delegate:blockchain.execute\"}", - "sha256_hash": "342ca6347bc0b852460090a1229273ec60695d58e45b5da60139fbcfa2a79fc0", - "challenge": "91af4c2e HARBOUR_DELEGATE 342ca6347bc0b852460090a1229273ec60695d58e45b5da60139fbcfa2a79fc0", - "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJ3YWxsZXRfY3JlZCIsIm9yZ19jcmVkIl0sImRlc2NyaXB0aW9uIjoiRXhlY3V0ZSBzZXR0bGVtZW50IiwiaWF0IjoxNzcxOTM0NDAwLCJub25jZSI6IjkxYWY0YzJlIiwidHJhbnNhY3Rpb25fZGF0YV9oYXNoZXNfYWxnIjpbInNoYS0yNTYiXSwidHhuIjp7ImNoYWluIjoiZWlwMTU1OjEiLCJjb250cmFjdCI6IjB4MTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTBhYmNkZWYxMjM0NTY3OCIsIm1ldGhvZCI6InNldHRsZSIsInBhcmFtcyI6eyJhbW91bnQiOiI0MjAwMDAwMDAwMDAwMDAwMDAwIiwiYXBwcm92ZXJzIjpbImRpZDp3ZWI6Ym13LmV4YW1wbGUiLCJkaWQ6d2ViOnN1cHBsaWVyLmV4YW1wbGUiXSwiZmxhZ3MiOnsiZ2FzbGVzcyI6ZmFsc2UsInVyZ2VudCI6dHJ1ZX0sInJlY2lwaWVudCI6IjB4QWJDZEVmMTIzNDU2Nzg5MGFCQ0RlZjEyMzQ1Njc4OTBhYkNEZWYxMiJ9LCJ2YWx1ZSI6IjAifSwidHlwZSI6ImhhcmJvdXJfZGVsZWdhdGU6YmxvY2tjaGFpbi5leGVjdXRlIn0", - "transaction_data_param_hash": "47JLdelRLXXAa_zuInDHy_zGQA0DrMu0V6W2cqwp1fU" + "canonical_json": "{\"credential_ids\":[\"wallet_cred\",\"org_cred\"],\"description\":\"Execute settlement\",\"iat\":1771934400,\"nonce\":\"91af4c2e\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"chain\":\"eip155:1\",\"contract\":\"0x1234567890abcdef1234567890abcdef12345678\",\"method\":\"settle\",\"params\":{\"amount\":\"4200000000000000000\",\"approvers\":[\"did:ethr:0x14a34:0x081d85aa2de20b04cf2e2114b56d7a3c025f69c1\",\"did:ethr:0x14a34:0x59404f9182101ca5c3e4b3c5dab9fb25bfa0b9ba\"],\"flags\":{\"gasless\":false,\"urgent\":true},\"recipient\":\"0xAbCdEf1234567890aBCDef1234567890abCDef12\"},\"value\":\"0\"},\"type\":\"harbour_delegate:blockchain.execute\"}", + "sha256_hash": "a00d54fed8213850dccbea6b4ecdf5dbec16156bc51c0a135c56e536e27f7b7a", + "challenge": "91af4c2e HARBOUR_DELEGATE a00d54fed8213850dccbea6b4ecdf5dbec16156bc51c0a135c56e536e27f7b7a", + "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJ3YWxsZXRfY3JlZCIsIm9yZ19jcmVkIl0sImRlc2NyaXB0aW9uIjoiRXhlY3V0ZSBzZXR0bGVtZW50IiwiaWF0IjoxNzcxOTM0NDAwLCJub25jZSI6IjkxYWY0YzJlIiwidHJhbnNhY3Rpb25fZGF0YV9oYXNoZXNfYWxnIjpbInNoYS0yNTYiXSwidHhuIjp7ImNoYWluIjoiZWlwMTU1OjEiLCJjb250cmFjdCI6IjB4MTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTBhYmNkZWYxMjM0NTY3OCIsIm1ldGhvZCI6InNldHRsZSIsInBhcmFtcyI6eyJhbW91bnQiOiI0MjAwMDAwMDAwMDAwMDAwMDAwIiwiYXBwcm92ZXJzIjpbImRpZDpldGhyOjB4MTRhMzQ6MHgwODFkODVhYTJkZTIwYjA0Y2YyZTIxMTRiNTZkN2EzYzAyNWY2OWMxIiwiZGlkOmV0aHI6MHgxNGEzNDoweDU5NDA0ZjkxODIxMDFjYTVjM2U0YjNjNWRhYjlmYjI1YmZhMGI5YmEiXSwiZmxhZ3MiOnsiZ2FzbGVzcyI6ZmFsc2UsInVyZ2VudCI6dHJ1ZX0sInJlY2lwaWVudCI6IjB4QWJDZEVmMTIzNDU2Nzg5MGFCQ0RlZjEyMzQ1Njc4OTBhYkNEZWYxMiJ9LCJ2YWx1ZSI6IjAifSwidHlwZSI6ImhhcmJvdXJfZGVsZWdhdGU6YmxvY2tjaGFpbi5leGVjdXRlIn0", + "transaction_data_param_hash": "k3i9tdr-DjCyQZOBLL1wcY9CIFFbBctvnTtOjpbbyws" } ] } diff --git a/tests/fixtures/sample-vc.json b/tests/fixtures/sample-vc.json index 66a4396..500dc2b 100644 --- a/tests/fixtures/sample-vc.json +++ b/tests/fixtures/sample-vc.json @@ -5,10 +5,10 @@ ], "type": ["VerifiableCredential"], "id": "urn:uuid:576fbefb-35e8-4b71-bb1a-53d1803c86de", - "issuer": "did:webs:reachhaven.com:ENVSnGVU_q39C0Lsim8CtXP_c0TbQW7BBndLVnBeDPXo", + "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "validFrom": "2025-08-06T10:15:22Z", "credentialSubject": { - "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", "type": "harbour:LegalPerson", "name": "Example Corporation GmbH" } diff --git a/tests/interop/test_cross_runtime.py b/tests/interop/test_cross_runtime.py index d4da410..34fe036 100644 --- a/tests/interop/test_cross_runtime.py +++ b/tests/interop/test_cross_runtime.py @@ -83,7 +83,7 @@ def test_vp_jose(self, sample_vp, p256_private_key): sample_vp, p256_private_key, nonce="interop-nonce", - audience="did:web:verifier.test", + audience="did:ethr:0x14a34:0x1e08782b4131f1f60f88c3ab6de01649f90bf7af", ) fixture = json.loads((KEYS_DIR / "test-keypair-p256.json").read_text()) pub_jwk = { @@ -99,7 +99,7 @@ def test_vp_jose(self, sample_vp, p256_private_key): const result = await compactVerify("{token}", key); const payload = JSON.parse(new TextDecoder().decode(result.payload)); if (payload.nonce !== "interop-nonce") throw new Error("nonce mismatch"); -if (payload.aud !== "did:web:verifier.test") throw new Error("aud mismatch"); +if (payload.aud !== "did:ethr:0x14a34:0x1e08782b4131f1f60f88c3ab6de01649f90bf7af") throw new Error("aud mismatch"); console.log("OK"); """ assert _run_node(script) == "OK" @@ -134,7 +134,7 @@ def test_vp_jose(self, p256_public_key): "type": ["VerifiablePresentation"], "verifiableCredential": [], "nonce": "cross-nonce", - "aud": "did:web:verifier.test", + "aud": "did:ethr:0x14a34:0x1e08782b4131f1f60f88c3ab6de01649f90bf7af", } script = f""" @@ -152,7 +152,7 @@ def test_vp_jose(self, p256_public_key): token, p256_public_key, expected_nonce="cross-nonce", - expected_audience="did:web:verifier.test", + expected_audience="did:ethr:0x14a34:0x1e08782b4131f1f60f88c3ab6de01649f90bf7af", ) assert result["type"] == ["VerifiablePresentation"] @@ -162,7 +162,10 @@ class TestPythonSDJWTNodeVerify: def test_sd_jwt_signature_interop(self, p256_private_key): """Python-issued SD-JWT-VC can be signature-verified by Node.js.""" - claims = {"iss": "did:web:test", "name": "Test"} + claims = { + "iss": "did:ethr:0x14a34:0x2e4daa1c54bd2ced7de6048cb26224d2fc52ccfd", + "name": "Test", + } sd_jwt = issue_sd_jwt_vc( claims, p256_private_key, @@ -205,7 +208,7 @@ def test_sd_jwt_from_node(self, p256_public_key): const key = await importJWK(jwk, "ES256"); const payload = new TextEncoder().encode(JSON.stringify({{ vct: "https://example.com/vc", - iss: "did:web:node-issuer", + iss: "did:ethr:0x14a34:0x927a94223e7cd1012bcf3851c1dcc0ff9f8eeda5", name: "NodeTest" }})); const signer = new CompactSign(payload); @@ -216,7 +219,10 @@ def test_sd_jwt_from_node(self, p256_public_key): """ sd_jwt = _run_node(script) result = verify_sd_jwt_vc(sd_jwt, p256_public_key) - assert result["iss"] == "did:web:node-issuer" + assert ( + result["iss"] + == "did:ethr:0x14a34:0x927a94223e7cd1012bcf3851c1dcc0ff9f8eeda5" + ) assert result["vct"] == "https://example.com/vc" diff --git a/tests/python/credentials/test_claim_mapping.py b/tests/python/credentials/test_claim_mapping.py index 90d0648..c577ffc 100644 --- a/tests/python/credentials/test_claim_mapping.py +++ b/tests/python/credentials/test_claim_mapping.py @@ -42,11 +42,11 @@ def test_vc_to_claims(self): assert ( claims["iss"] - == "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ" + == "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697" ) assert ( claims["sub"] - == "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" + == "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" ) assert claims["name"] == "Example Corporation GmbH" # Base mapping has no gx claims @@ -269,15 +269,15 @@ def test_register_and_use_custom_mapping(self): # Use it vc = { "type": ["VerifiableCredential", "harbour:CustomCredential"], - "issuer": "did:web:issuer.example.com", + "issuer": "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { - "id": "did:web:subject.example.com", + "id": "did:ethr:0x14a34:0xe21cf53752b534301cd285712734ab1710260543", "customField": "custom-value", }, "credentialStatus": [ { - "id": "did:web:issuer.example.com:revocation#abc123", + "id": "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c:revocation#abc123", "type": "harbour:CRSetEntry", "statusPurpose": "revocation", } diff --git a/tests/python/credentials/test_example_signer.py b/tests/python/credentials/test_example_signer.py index c4a0df0..d0afb16 100644 --- a/tests/python/credentials/test_example_signer.py +++ b/tests/python/credentials/test_example_signer.py @@ -58,15 +58,15 @@ def test_sign_evidence_vp(self, signing_key): vp = { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "holder": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", "verifiableCredential": [ { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiableCredential"], - "issuer": "did:web:notary.example.com", + "issuer": "did:ethr:0x14a34:0x7863e20b04934e8a439e196beac92f3cc3b3676c", "validFrom": "2024-01-10T00:00:00Z", "credentialSubject": { - "id": "did:webs:participants.harbour.reachhaven.com:legal-persons:0aa6d7ea-27ef-416f-abf8-9cb634884e66:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", + "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", "type": "gx:LegalPerson", }, } @@ -102,7 +102,7 @@ def test_decode_evidence_vp(self, signing_key): { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiableCredential"], - "issuer": "did:web:notary.example.com", + "issuer": "did:ethr:0x14a34:0x7863e20b04934e8a439e196beac92f3cc3b3676c", "validFrom": "2024-01-10T00:00:00Z", "credentialSubject": {"id": "did:example:sub"}, } @@ -189,7 +189,7 @@ def test_process_delegated_signing_receipt(self, signing_key, tmp_path): assert evidence["transaction_data"]["type"] == "harbour_delegate:data.purchase" assert ( evidence["delegatedTo"] - == "did:webs:harbour.reachhaven.com:Er9_mnFstIFyj7JXhHtf7BTHAaUXkaFoJQq96z8WycDQ" + == "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697" ) # Evidence VP should be a signed JWT diff --git a/tests/python/credentials/test_sign_examples.py b/tests/python/credentials/test_sign_examples.py index 5dd6d88..a98049b 100644 --- a/tests/python/credentials/test_sign_examples.py +++ b/tests/python/credentials/test_sign_examples.py @@ -30,7 +30,9 @@ def test_tamper_detection_jose( parts = token.split(".") payload = json.loads(base64.urlsafe_b64decode(parts[1] + "==")) - payload["credentialSubject"]["id"] = "did:web:evil.example.com" + payload["credentialSubject"][ + "id" + ] = "did:ethr:0x14a34:0x81c6d42b1781bb3bb7a280f564d66ec9d41beace" tampered_payload = ( base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=").decode() ) diff --git a/tests/python/harbour/test_kb_jwt.py b/tests/python/harbour/test_kb_jwt.py index f61c733..93447a0 100644 --- a/tests/python/harbour/test_kb_jwt.py +++ b/tests/python/harbour/test_kb_jwt.py @@ -11,7 +11,7 @@ from harbour.verifier import VerificationError SAMPLE_CLAIMS = { - "iss": "did:web:issuer.example.com", + "iss": "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", "legalName": "Example Corporation GmbH", "email": "info@example.com", } diff --git a/tests/python/harbour/test_sd_jwt.py b/tests/python/harbour/test_sd_jwt.py index fb3f4a7..cff9562 100644 --- a/tests/python/harbour/test_sd_jwt.py +++ b/tests/python/harbour/test_sd_jwt.py @@ -10,7 +10,7 @@ from harbour.verifier import VerificationError SAMPLE_CLAIMS = { - "iss": "did:web:issuer.example.com", + "iss": "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", "iat": 1723972522, "exp": 1913990400, "legalName": "Example Corporation GmbH", @@ -68,7 +68,10 @@ def test_verify_all_disclosed(self, p256_private_key, p256_public_key): sd_jwt = issue_sd_jwt_vc(SAMPLE_CLAIMS, p256_private_key, vct=VCT) result = verify_sd_jwt_vc(sd_jwt, p256_public_key) assert result["legalName"] == "Example Corporation GmbH" - assert result["iss"] == "did:web:issuer.example.com" + assert ( + result["iss"] + == "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c" + ) def test_verify_with_selective_disclosure(self, p256_private_key, p256_public_key): sd_jwt = issue_sd_jwt_vc( diff --git a/tests/python/harbour/test_sd_jwt_vp.py b/tests/python/harbour/test_sd_jwt_vp.py index 08f945b..196d76f 100644 --- a/tests/python/harbour/test_sd_jwt_vp.py +++ b/tests/python/harbour/test_sd_jwt_vp.py @@ -62,7 +62,7 @@ def sample_sd_jwt_vc(issuer_keypair, holder_keypair): # SD-JWT-VC uses flat claims (not nested credentialSubject) claims = { - "iss": "did:web:issuer.example.com", + "iss": "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", "sub": holder_did, "givenName": "Alice", "familyName": "Smith", @@ -93,7 +93,7 @@ def test_issue_basic_vp(self, sample_sd_jwt_vc, holder_keypair): sample_sd_jwt_vc, holder_private, nonce="test-nonce-123", - audience="did:web:verifier.example.com", + audience="did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0", ) # Should be a ~-separated string @@ -153,7 +153,7 @@ def test_issue_with_evidence(self, sample_sd_jwt_vc, holder_keypair): """Test VP issuance with DelegatedSignatureEvidence.""" holder_private, _ = holder_keypair tx_nonce = "tx-consent-nonce" - audience = "did:web:signing-service.example.com" + audience = "did:ethr:0x14a34:0xab645824d16971b89a2243f21881864ad57b9166" evidence = [ { @@ -253,7 +253,7 @@ def test_verify_basic_vp(self, sample_sd_jwt_vc, issuer_keypair, holder_keypair) holder_private, holder_public = holder_keypair nonce = "verify-test-nonce" - audience = "did:web:verifier.example.com" + audience = "did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0" vp = issue_sd_jwt_vp( sample_sd_jwt_vc, @@ -395,11 +395,13 @@ def test_verify_fails_internal_audience_mismatch( sample_sd_jwt_vc, holder_private, nonce="aud-nonce", - audience="did:web:signing-service.example.com", + audience="did:ethr:0x14a34:0xab645824d16971b89a2243f21881864ad57b9166", ) parts = vp.split("~") kb_payload = _decode_jwt_payload(parts[-1]) - kb_payload["aud"] = "did:web:evil.example.com" + kb_payload["aud"] = ( + "did:ethr:0x14a34:0x81c6d42b1781bb3bb7a280f564d66ec9d41beace" + ) tampered_kb_jwt = _resign_jwt(parts[-1], kb_payload, holder_private) tampered_vp = "~".join(parts[:-1] + [tampered_kb_jwt]) @@ -493,7 +495,7 @@ def test_verify_fails_audience_mismatch( vp = issue_sd_jwt_vp( sample_sd_jwt_vc, holder_private, - audience="did:web:expected-verifier.example.com", + audience="did:ethr:0x14a34:0x3c85cd0f7f6333319143a1b21306a3339445ad0a", ) with pytest.raises(VerificationError, match="Audience mismatch"): @@ -501,7 +503,7 @@ def test_verify_fails_audience_mismatch( vp, issuer_public, holder_public, - expected_audience="did:web:wrong-verifier.example.com", + expected_audience="did:ethr:0x14a34:0x33d113d8e2426612046f29da322c159855de0ba0", ) @@ -522,7 +524,7 @@ def test_delegated_consent_flow(self, issuer_keypair, holder_keypair): # Step 1: Issue credential to holder (SD-JWT-VC uses flat claims) claims = { - "iss": "did:web:trusted-issuer.example.com", + "iss": "did:ethr:0x14a34:0xb0771a9447399cd33e0cad1228a33ac914715105", "sub": holder_did, "givenName": "Carlo", "familyName": "Rossi", @@ -538,7 +540,9 @@ def test_delegated_consent_flow(self, issuer_keypair, holder_keypair): ) # Step 2: Holder creates consent VP - signing_service_did = "did:webs:harbour.signing-service.example.com" + signing_service_did = ( + "did:ethr:0x14a34:0x1d19e55b17c018b6704b8217c95975d97e531269" + ) consent_nonce = secrets.token_urlsafe(32) transaction_data = { @@ -616,7 +620,7 @@ def test_public_audit_privacy(self, issuer_keypair, holder_keypair): # Issue credential with PII (SD-JWT-VC uses flat claims) claims = { - "iss": "did:web:issuer.example.com", + "iss": "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", "sub": holder_did, "name": "Confidential Person", "email": "secret@example.com", diff --git a/tests/python/harbour/test_sign.py b/tests/python/harbour/test_sign.py index c1a1e31..1caf039 100644 --- a/tests/python/harbour/test_sign.py +++ b/tests/python/harbour/test_sign.py @@ -85,11 +85,13 @@ def test_sign_vp_jose_nonce_and_audience(sample_vp, p256_private_key): sample_vp, p256_private_key, nonce="challenge-123", - audience="did:web:verifier.example.com", + audience="did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0", ) payload = _decode_payload(token) assert payload["nonce"] == "challenge-123" - assert payload["aud"] == "did:web:verifier.example.com" + assert ( + payload["aud"] == "did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0" + ) # Original VP fields are preserved assert payload["type"] == sample_vp["type"] diff --git a/tests/python/harbour/test_tamper.py b/tests/python/harbour/test_tamper.py index a6ab6ea..58723fc 100644 --- a/tests/python/harbour/test_tamper.py +++ b/tests/python/harbour/test_tamper.py @@ -15,7 +15,9 @@ def test_tamper_payload(sample_vc, p256_private_key, p256_public_key): # Decode payload, tamper, re-encode payload = json.loads(base64.urlsafe_b64decode(parts[1] + "==")) - payload["credentialSubject"]["id"] = "did:web:evil.example.com" + payload["credentialSubject"][ + "id" + ] = "did:ethr:0x14a34:0x81c6d42b1781bb3bb7a280f564d66ec9d41beace" tampered_payload = ( base64.urlsafe_b64encode( json.dumps(payload, ensure_ascii=False).encode("utf-8") @@ -49,7 +51,7 @@ def test_tamper_header(sample_vc, p256_private_key, p256_public_key): # Decode header, tamper alg header = json.loads(base64.urlsafe_b64decode(parts[0] + "==")) - header["kid"] = "did:web:evil.example.com#key-1" + header["kid"] = "did:ethr:0x14a34:0x81c6d42b1781bb3bb7a280f564d66ec9d41beace#key-1" tampered_header = ( base64.urlsafe_b64encode(json.dumps(header).encode()).rstrip(b"=").decode() ) diff --git a/tests/python/harbour/test_verify.py b/tests/python/harbour/test_verify.py index 3146cde..e6fa7e4 100644 --- a/tests/python/harbour/test_verify.py +++ b/tests/python/harbour/test_verify.py @@ -62,17 +62,19 @@ def test_verify_vp_jose_valid(sample_vp, p256_private_key, p256_public_key): sample_vp, p256_private_key, nonce="test-nonce", - audience="did:web:verifier.example.com", + audience="did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0", ) result = verify_vp_jose( token, p256_public_key, expected_nonce="test-nonce", - expected_audience="did:web:verifier.example.com", + expected_audience="did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0", ) assert result["type"] == sample_vp["type"] assert result["nonce"] == "test-nonce" - assert result["aud"] == "did:web:verifier.example.com" + assert ( + result["aud"] == "did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0" + ) def test_verify_vp_jose_wrong_nonce_fails(sample_vp, p256_private_key, p256_public_key): @@ -85,11 +87,15 @@ def test_verify_vp_jose_wrong_audience_fails( sample_vp, p256_private_key, p256_public_key ): token = sign_vp_jose( - sample_vp, p256_private_key, audience="did:web:real.example.com" + sample_vp, + p256_private_key, + audience="did:ethr:0x14a34:0x6176cb54dc4498765590d7e5522523ef9e634906", ) with pytest.raises(VerificationError, match="Audience mismatch"): verify_vp_jose( - token, p256_public_key, expected_audience="did:web:evil.example.com" + token, + p256_public_key, + expected_audience="did:ethr:0x14a34:0x81c6d42b1781bb3bb7a280f564d66ec9d41beace", ) diff --git a/tests/python/harbour/test_x509.py b/tests/python/harbour/test_x509.py index 9214861..11ffe24 100644 --- a/tests/python/harbour/test_x509.py +++ b/tests/python/harbour/test_x509.py @@ -108,8 +108,12 @@ def test_sign_verify_via_x5c(self): vc = { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiableCredential"], - "issuer": {"id": "did:web:example.com"}, - "credentialSubject": {"id": "did:web:holder.example.com"}, + "issuer": { + "id": "did:ethr:0x14a34:0x4ff70ba2fe8c4724a11da529381cbc391e5d8423" + }, + "credentialSubject": { + "id": "did:ethr:0x14a34:0x27bfcb7f5bf1c5fe45777694cfa4a499cb61711d" + }, } token = sign_vc_jose(vc, priv, x5c=x5c) @@ -118,4 +122,7 @@ def test_sign_verify_via_x5c(self): certs = x5c_to_certs(x5c) pub = extract_public_key(certs[0]) result = verify_vc_jose(token, pub) - assert result["issuer"]["id"] == "did:web:example.com" + assert ( + result["issuer"]["id"] + == "did:ethr:0x14a34:0x4ff70ba2fe8c4724a11da529381cbc391e5d8423" + ) diff --git a/tests/typescript/harbour/kb-jwt.test.ts b/tests/typescript/harbour/kb-jwt.test.ts index 29318da..5950a2d 100644 --- a/tests/typescript/harbour/kb-jwt.test.ts +++ b/tests/typescript/harbour/kb-jwt.test.ts @@ -16,7 +16,7 @@ import { const FIXTURES_DIR = resolve(__dirname, "../../fixtures"); const SAMPLE_CLAIMS = { - iss: "did:web:issuer.example.com", + iss: "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", iat: Math.floor(Date.now() / 1000), legalName: "Test Corp", }; @@ -45,7 +45,7 @@ describe("KB-JWT creation", () => { const withKb = await createKbJwt(sdJwt, holderPrivateKey, { nonce: "test-nonce-123", - audience: "did:web:verifier.example.com", + audience: "did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0", }); // Should have one more ~ segment than the original @@ -63,14 +63,14 @@ describe("KB-JWT creation", () => { const withKb = await createKbJwt(sdJwt, holderPrivateKey, { nonce: "test-nonce", - audience: "did:web:verifier.example.com", + audience: "did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0", transaction_data: ["tx1", "tx2"], }); // Verify and check payload const payload = await verifyKbJwt(withKb, holderPublicKey, { expectedNonce: "test-nonce", - expectedAudience: "did:web:verifier.example.com", + expectedAudience: "did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0", expected_transaction_data: ["tx1", "tx2"], }); diff --git a/tests/typescript/harbour/sd-jwt-vp.test.ts b/tests/typescript/harbour/sd-jwt-vp.test.ts index 1af7c33..c3cda5a 100644 --- a/tests/typescript/harbour/sd-jwt-vp.test.ts +++ b/tests/typescript/harbour/sd-jwt-vp.test.ts @@ -63,7 +63,7 @@ beforeAll(async () => { // SD-JWT-VC uses flat claims const claims = { - iss: "did:web:issuer.example.com", + iss: "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", sub: holderDid, givenName: "Alice", familyName: "Smith", @@ -86,7 +86,7 @@ describe("issueSdJwtVp", () => { it("issues a basic VP with all disclosures", async () => { const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { nonce: "test-nonce-123", - audience: "did:web:verifier.example.com", + audience: "did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0", }); expect(vp).toContain("~"); @@ -121,7 +121,7 @@ describe("issueSdJwtVp", () => { it("issues with evidence", async () => { const txNonce = "tx-consent-nonce"; - const audience = "did:web:signing-service.example.com"; + const audience = "did:ethr:0x14a34:0xab645824d16971b89a2243f21881864ad57b9166"; const evidence = [ { type: "DelegatedSignatureEvidence", @@ -208,7 +208,7 @@ describe("issueSdJwtVp", () => { describe("verifySdJwtVp", () => { it("verifies a basic VP", async () => { const nonce = "verify-test-nonce"; - const audience = "did:web:verifier.example.com"; + const audience = "did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0"; const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { nonce, @@ -308,11 +308,11 @@ describe("verifySdJwtVp", () => { it("fails when VP and KB audiences differ", async () => { const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { nonce: "aud-nonce", - audience: "did:web:signing-service.example.com", + audience: "did:ethr:0x14a34:0xab645824d16971b89a2243f21881864ad57b9166", }); const parts = vp.split("~"); const kbPayload = decodeJwtPayload(parts[parts.length - 1]); - kbPayload.aud = "did:web:evil.example.com"; + kbPayload.aud = "did:ethr:0x14a34:0x81c6d42b1781bb3bb7a280f564d66ec9d41beace"; const tamperedKbJwt = await resignJwt( parts[parts.length - 1], kbPayload, @@ -386,12 +386,12 @@ describe("verifySdJwtVp", () => { it("fails with audience mismatch", async () => { const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { - audience: "did:web:expected.example.com", + audience: "did:ethr:0x14a34:0x62ed6f3003261ad826c5c4adae4934072f772dae", }); await expect( verifySdJwtVp(vp, issuerPublic, holderPublic, { - expectedAudience: "did:web:wrong.example.com", + expectedAudience: "did:ethr:0x14a34:0x62f7f3546fdd7c013d1f206179d867c13bcb47da", }) ).rejects.toThrow(/Audience mismatch/); }); diff --git a/tests/typescript/harbour/sd-jwt.test.ts b/tests/typescript/harbour/sd-jwt.test.ts index 00c5b76..3e447c8 100644 --- a/tests/typescript/harbour/sd-jwt.test.ts +++ b/tests/typescript/harbour/sd-jwt.test.ts @@ -15,7 +15,7 @@ const VCT = "https://w3id.org/reachhaven/harbour/credentials/v1/LegalPersonCredential"; const SAMPLE_CLAIMS = { - iss: "did:web:issuer.example.com", + iss: "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", iat: 1723972522, legalName: "Example Corporation GmbH", legalForm: "GmbH", diff --git a/tests/typescript/harbour/sign.test.ts b/tests/typescript/harbour/sign.test.ts index faa627b..ee925bb 100644 --- a/tests/typescript/harbour/sign.test.ts +++ b/tests/typescript/harbour/sign.test.ts @@ -86,11 +86,11 @@ describe("signVpJose", () => { it("includes nonce and audience in payload", async () => { const token = await signVpJose(sampleVp, privateKey, { nonce: "test-nonce", - audience: "did:web:verifier.example.com", + audience: "did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0", }); const payload = decodePayload(token); expect(payload.nonce).toBe("test-nonce"); - expect(payload.aud).toBe("did:web:verifier.example.com"); + expect(payload.aud).toBe("did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0"); expect(payload.type).toEqual(["VerifiablePresentation"]); }); }); diff --git a/tests/typescript/harbour/tamper.test.ts b/tests/typescript/harbour/tamper.test.ts index ee0e94e..2478359 100644 --- a/tests/typescript/harbour/tamper.test.ts +++ b/tests/typescript/harbour/tamper.test.ts @@ -32,7 +32,7 @@ describe("tamper detection", () => { Buffer.from(parts[1], "base64url").toString(), ) as Record; (payload.credentialSubject as any).id = - "did:web:evil.example.com"; + "did:ethr:0x14a34:0x81c6d42b1781bb3bb7a280f564d66ec9d41beace"; const tampered = Buffer.from(JSON.stringify(payload)).toString("base64url"); const tamperedToken = `${parts[0]}.${tampered}.${parts[2]}`; @@ -65,7 +65,7 @@ describe("tamper detection", () => { const header = JSON.parse( Buffer.from(parts[0], "base64url").toString(), ); - header.kid = "did:web:evil.example.com#key-1"; + header.kid = "did:ethr:0x14a34:0x81c6d42b1781bb3bb7a280f564d66ec9d41beace#key-1"; const tampered = Buffer.from(JSON.stringify(header)).toString("base64url"); const tamperedToken = `${tampered}.${parts[1]}.${parts[2]}`; diff --git a/tests/typescript/harbour/verify.test.ts b/tests/typescript/harbour/verify.test.ts index 781ddf6..538db54 100644 --- a/tests/typescript/harbour/verify.test.ts +++ b/tests/typescript/harbour/verify.test.ts @@ -57,11 +57,11 @@ describe("verifyVpJose", () => { it("validates nonce and audience", async () => { const token = await signVpJose(sampleVp, privateKey, { nonce: "test-nonce", - audience: "did:web:verifier.example.com", + audience: "did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0", }); const result = await verifyVpJose(token, publicKey, { expectedNonce: "test-nonce", - expectedAudience: "did:web:verifier.example.com", + expectedAudience: "did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0", }); expect(result.type).toEqual(["VerifiablePresentation"]); expect(result.nonce).toBe("test-nonce"); @@ -76,10 +76,10 @@ describe("verifyVpJose", () => { it("throws on wrong audience", async () => { const token = await signVpJose(sampleVp, privateKey, { - audience: "did:web:real.example.com", + audience: "did:ethr:0x14a34:0x6176cb54dc4498765590d7e5522523ef9e634906", }); await expect( - verifyVpJose(token, publicKey, { expectedAudience: "did:web:evil.example.com" }), + verifyVpJose(token, publicKey, { expectedAudience: "did:ethr:0x14a34:0x81c6d42b1781bb3bb7a280f564d66ec9d41beace" }), ).rejects.toThrow(VerificationError); }); From fbb3fb70b7f0e41c35b0ea67d1b1ee6215498226 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Tue, 10 Mar 2026 15:20:24 +0100 Subject: [PATCH 18/78] refactor(linkml): SHACL ghost properties, model audit, and markdownlint tooling LinkML schema improvements: - Eliminate SHACL ghost properties (remove id from class slots lists) - Fix Evidence class_uri to harbour:Evidence namespace - Add HarbourShaclGenerator with cred:issuer nodeKind fix - Clean up harbour-core-credential and harbour-gx-credential schemas - Add w3c-vc.yaml schema for transitive imports Artifact generation: - Replace gen-shacl/gen-owl/gen-jsonld-context CLI calls with unified generate_artifacts.py script - Add DomainContextGenerator to exclude W3C @protected terms Documentation: - Add reference specs (did-core, oid4vp, vc-data-model-2.0, sd-jwt-rfc9901, gx-architecture-document-25.11) - Convert did-web-method.txt to proper markdown - Fix markdown lint violations across all docs (MD040, MD046, MD036, MD032) Markdown lint tooling: - Add .markdownlint-cli2.yaml config with tuned rules - Add markdownlint-cli2 pre-commit hook (v0.17.2) - Add lint-md and format-md Makefile targets - Add lint-markdown CI job in GitHub Actions workflow Signed-off-by: Carlo van Driesten --- .github/copilot-instructions.md | 2 +- .github/workflows/ci.yml | 13 + .markdownlint-cli2.yaml | 71 +++ .pre-commit-config.yaml | 6 + AGENTS.md | 5 +- CLAUDE.md | 3 + Makefile | 22 +- README.md | 3 +- docs/architecture.md | 4 +- docs/contributing.md | 2 +- docs/decisions/001-vc-securing-mechanism.md | 2 +- .../002-dual-runtime-architecture.md | 9 +- docs/decisions/003-canonicalization.md | 2 +- docs/decisions/004-key-management.md | 8 + docs/decisions/005-did-ethr-migration.md | 3 +- docs/getting-started/installation.md | 18 +- docs/getting-started/quickstart.md | 158 +++---- docs/guide/delegated-signing.md | 3 +- docs/index.md | 68 +-- docs/specs/delegation-challenge-encoding.md | 25 +- docs/specs/did-method-evaluation.md | 2 +- docs/specs/references/README.md | 34 +- docs/specs/references/csc-data-model.md | 6 + docs/specs/references/did-core.md | 80 ++++ docs/specs/references/did-ethr-method-spec.md | 28 +- docs/specs/references/did-web-method.txt | 429 ------------------ docs/specs/references/did-webs-spec.md | 132 ++++-- .../gx-architecture-document-25.11.md | 108 +++++ docs/specs/references/keri-draft.md | 121 ++--- docs/specs/references/oid4vp-1.0.md | 92 ++++ docs/specs/references/sd-jwt-rfc9901.md | 99 ++++ docs/specs/references/sd-jwt-vc.md | 12 +- docs/specs/references/vc-data-model-2.0.md | 114 +++++ docs/specs/references/vc-jose-cose.md | 8 + examples/README.md | 1 - examples/did-ethr/README.md | 5 +- linkml/harbour-core-credential.yaml | 209 +++++---- linkml/harbour-gx-credential.yaml | 65 ++- linkml/w3c-vc.yaml | 96 ++++ src/python/harbour/generate_artifacts.py | 30 +- 40 files changed, 1251 insertions(+), 847 deletions(-) create mode 100644 .markdownlint-cli2.yaml create mode 100644 docs/specs/references/did-core.md delete mode 100644 docs/specs/references/did-web-method.txt create mode 100644 docs/specs/references/gx-architecture-document-25.11.md create mode 100644 docs/specs/references/oid4vp-1.0.md create mode 100644 docs/specs/references/sd-jwt-rfc9901.md create mode 100644 docs/specs/references/vc-data-model-2.0.md create mode 100644 linkml/w3c-vc.yaml diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9dcb20a..a48a990 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -48,7 +48,7 @@ Read these BEFORE making changes: ## Project Structure -``` +```text src/ ├── python/ │ ├── harbour/ # Crypto library (keys, sign, verify, sd-jwt, kb-jwt, x509) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aac6b36..e43acd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,19 @@ jobs: - name: Run pre-commit run: make lint + lint-markdown: + name: Lint (Markdown) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Lint Markdown + run: npx --yes markdownlint-cli2 + lint-ts: name: Lint (TypeScript) runs-on: ubuntu-latest diff --git a/.markdownlint-cli2.yaml b/.markdownlint-cli2.yaml new file mode 100644 index 0000000..fa87e84 --- /dev/null +++ b/.markdownlint-cli2.yaml @@ -0,0 +1,71 @@ +# markdownlint-cli2 configuration +# https://github.com/DavidAnson/markdownlint-cli2#configuration + +config: + # MD003 - Heading style + MD003: + style: consistent + + # MD004 - Unordered list style + MD004: + style: consistent + + # MD007 - Unordered list indentation + MD007: + indent: 2 + + # MD013 - Line length (disabled — handled by editors/soft-wrap) + MD013: false + + # MD024 - Multiple headings with the same content (allow in different nesting) + MD024: + siblings_only: true + + # MD033 - Inline HTML (allow common elements used in docs) + MD033: + allowed_elements: + - a + - br + - details + - summary + - img + - sup + - sub + + # MD034 - Bare URLs (disabled — common in spec reference docs) + MD034: false + + # MD041 - First line should be a top-level heading (disabled for includes) + MD041: false + + # MD046 - Code block style + MD046: + style: fenced + + # MD048 - Code fence style + MD048: + style: backtick + + # MD049 - Emphasis style + MD049: + style: consistent + + # MD050 - Strong style + MD050: + style: consistent + + # MD051 - Link fragments should be valid (disabled — many anchors are dynamic) + MD051: false + + # MD060 - Table column style (disabled — overly strict on existing tables) + MD060: false + +globs: + - "**/*.md" + - "!.venv/**" + - "!node_modules/**" + - "!submodules/**" + - "!.pytest_cache/**" + - "!.playground/**" + - "!src/typescript/harbour/.yarn/**" + - "!docs/specs/references/**" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9a293d7..54ccf95 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,3 +25,9 @@ repos: types: [python] args: ["--config=.flake8"] pass_filenames: true + + - repo: https://github.com/DavidAnson/markdownlint-cli2 + rev: v0.17.2 + hooks: + - id: markdownlint-cli2 + args: ["--config", ".markdownlint-cli2.yaml"] diff --git a/AGENTS.md b/AGENTS.md index 0c9c86f..90d6d87 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,7 +12,7 @@ Read these before making changes; they are authoritative for repo workflows. ## Project Structure -``` +```text src/ ├── python/ │ ├── harbour/ # Crypto library (sign, verify, keys, sd-jwt, kb-jwt, x509) @@ -78,6 +78,7 @@ When instructed to prepare a commit or PR, **do not commit directly**. Instead: - `.playground/pr-description.md` — PR description following the repository's PR template The human operator will review these files and either: + - Use them to manually commit/push and create a PR, or - Use automated tooling with signed commits (`git commit -s -S`) @@ -125,6 +126,7 @@ Closes #42 ## Coding Style ### Python + - Python 3.12+ with type hints on public APIs - Use `pathlib.Path` (not `os.path`) - 4-space indentation @@ -132,6 +134,7 @@ Closes #42 - Run `make lint` before committing ### TypeScript + - TypeScript 5.x with strict mode - Use async/await for crypto operations - Export types alongside functions diff --git a/CLAUDE.md b/CLAUDE.md index d54360d..ab04008 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,7 @@ Python (`src/python/harbour/`) and TypeScript (`src/typescript/harbour/`) implem ### Test Layout Tests live in `tests/` with shared fixtures: + - `tests/fixtures/` — shared keys (`keys/`), tokens (`tokens/`), credentials (`credentials/`), `sample-vc.json` - `tests/python/harbour/` — Python harbour module tests - `tests/python/credentials/` — Python credentials pipeline tests @@ -134,12 +135,14 @@ All Python modules have CLI interfaces: `python -m harbour.keys --help`, `python ## Coding Conventions ### Python + - **Python 3.12+** with type hints on public APIs - **pathlib.Path** (never `os.path`) - All modules must have `main()` with `argparse` and `--help` - Formatter: black (line-length 88), isort (profile: black) ### TypeScript + - **TypeScript 5.x** with strict mode, ES2022 target - **async/await** for crypto operations - Export types alongside functions diff --git a/Makefile b/Makefile index ccc5073..39913de 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Harbour Credentials Makefile # ============================ -.PHONY: setup install install-dev submodule-setup ts-bootstrap generate validate validate-shacl lint format test test-cov test-ts test-interop build-ts lint-ts test-all all clean help +.PHONY: setup install install-dev submodule-setup ts-bootstrap generate validate validate-shacl lint lint-md format format-md test test-cov test-ts test-interop build-ts lint-ts test-all all clean help TS_DIR := src/typescript/harbour OMB_SUBMODULE_DIR := submodules/ontology-management-base @@ -78,9 +78,11 @@ help: @echo " make validate-shacl - Run SHACL conformance on examples (via ontology-management-base)" @echo "" @echo "Linting:" - @echo " make lint - Run pre-commit checks (Python)" - @echo " make lint-ts - Run TypeScript linting" - @echo " make format - Format Python code with black/isort" + @echo " make lint - Run pre-commit checks (Python + Markdown)" + @echo " make lint-md - Lint Markdown files with markdownlint-cli2" + @echo " make lint-ts - Run TypeScript linting" + @echo " make format - Format Python code with black/isort" + @echo " make format-md - Auto-fix Markdown lint violations" @echo "" @echo "Testing:" @echo " make test - Run Python pytest suite" @@ -226,6 +228,12 @@ lint: @$(PYTHON) -m pre_commit run --all-files @echo "OK: Pre-commit checks complete" +# Lint Markdown files +lint-md: ## Lint Markdown files with markdownlint-cli2 + @echo "Linting Markdown files..." + @npx --yes markdownlint-cli2 + @echo "OK: Markdown lint complete" + # Auto-format code format: $(call check_dev_setup) @@ -234,6 +242,12 @@ format: @$(PYTHON) -m isort src/python/ tests/ @echo "OK: Python formatting complete" +# Auto-fix Markdown lint violations +format-md: ## Auto-fix Markdown lint violations + @echo "Fixing Markdown files..." + @npx --yes markdownlint-cli2 --fix + @echo "OK: Markdown formatting complete" + # Run tests test: $(call check_dev_setup) diff --git a/README.md b/README.md index 631a056..c9776a7 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,6 @@ Harbour provides a base credential framework (`harbour-core-credential.yaml`) wi | `harbour:LegalPersonCredential` | `harbour:LegalPerson` | `gxParticipant` | `gx:LegalPerson` | | `harbour:NaturalPersonCredential` | `harbour:NaturalPerson` | `gxParticipant` | `gx:Participant` | - All harbour credentials require: - `issuer` - DID of the credential issuer @@ -197,7 +196,7 @@ python -m harbour.x509 generate --key key.jwk --subject "Test Issuer" --output c ## Package Structure -``` +```text src/ ├── python/ │ ├── harbour/ # Crypto library diff --git a/docs/architecture.md b/docs/architecture.md index 838689f..87a5c3c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,7 +2,7 @@ ## Package Structure (Current) -``` +```text harbour-credentials/ ├── src/ │ ├── python/ @@ -44,7 +44,7 @@ harbour-credentials/ ## Format Relationship -``` +```text LinkML Schema → JSON-LD Context + SHACL (schema validation) │ ┌────────────┼────────────┐ diff --git a/docs/contributing.md b/docs/contributing.md index 373f57c..9386317 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -90,7 +90,7 @@ When adding features, implement in **both** Python and TypeScript to maintain fe Use [Conventional Commits](https://www.conventionalcommits.org/): -``` +```text feat(harbour): add KB-JWT verification fix(sd-jwt): handle empty disclosure arrays docs: update quickstart guide diff --git a/docs/decisions/001-vc-securing-mechanism.md b/docs/decisions/001-vc-securing-mechanism.md index 5684ab7..cd6cfd5 100644 --- a/docs/decisions/001-vc-securing-mechanism.md +++ b/docs/decisions/001-vc-securing-mechanism.md @@ -170,7 +170,7 @@ Ed25519 is also supported for testing, but **ES256 MUST be the default** for EUD ## Relationship Between Formats -``` +```text ┌────────────────────────────────────────┐ │ LinkML Schema Definition │ │ (harbour-core-credential.yaml, etc.) │ diff --git a/docs/decisions/002-dual-runtime-architecture.md b/docs/decisions/002-dual-runtime-architecture.md index 4012172..730a5ac 100644 --- a/docs/decisions/002-dual-runtime-architecture.md +++ b/docs/decisions/002-dual-runtime-architecture.md @@ -19,7 +19,7 @@ Both groups need to sign and verify the same credentials. If the Python implemen ### Repository Structure -``` +```text harbour-credentials/ ├── src/ │ ├── python/ @@ -62,7 +62,8 @@ The interop guarantee is enforced by **shared test fixtures**: 4. `tests/fixtures/tokens/signed-vc-p256.jwt` — Reference signed JWT (committed, deterministic) **Interop test pattern:** -``` + +```text Python signs sample-vc.json → JWT string → JavaScript verifies ✓ JavaScript signs sample-vc.json → JWT string → Python verifies ✓ Both produce identical JWT for same input + key ✓ @@ -81,6 +82,7 @@ jobs: ``` The interop job: + 1. Installs both Python and Node.js 2. Python signs → writes JWT to stdout → Node.js reads and verifies 3. Node.js signs → writes JWT to stdout → Python reads and verifies @@ -96,17 +98,20 @@ The interop job: ## Consequences ### Positive + - Web developers can `npm install` the JS package and sign/verify immediately - Python developers can `pip install` the Python package - Cross-runtime bugs are caught by CI, not in production - Proves the format is truly standard (if two independent implementations agree, it works) ### Negative + - Repository needs both Python and Node.js tooling - CI pipeline is more complex (two runtimes) - Contributors need familiarity with both ecosystems (or can focus on one) ### Decided Since Initial Proposal + - **npm package name** — `@reachhaven/harbour-credentials` - **JS test framework** — vitest (fast, ESM-native) - **Package manager** — Yarn 4 via corepack diff --git a/docs/decisions/003-canonicalization.md b/docs/decisions/003-canonicalization.md index 8e2ded4..6679584 100644 --- a/docs/decisions/003-canonicalization.md +++ b/docs/decisions/003-canonicalization.md @@ -36,7 +36,7 @@ In standard JWT signing: 3. The verifier decodes the same bytes from the JWT 4. The signature covers the exact bytes in the JWT, not a re-serialized version -``` +```text Signer: VC dict → JSON bytes → base64url → JWT(header.payload.signature) Verifier: JWT → base64url decode → JSON bytes → verify signature → parse JSON ``` diff --git a/docs/decisions/004-key-management.md b/docs/decisions/004-key-management.md index 403291a..1ac10e2 100644 --- a/docs/decisions/004-key-management.md +++ b/docs/decisions/004-key-management.md @@ -8,6 +8,7 @@ ## Context Verifiable Credentials require: + 1. A **signing algorithm** — determines the cryptographic primitives 2. A **key format** — how keys are serialized and exchanged 3. A **key resolution method** — how a verifier discovers the public key from the credential @@ -19,12 +20,14 @@ Verifiable Credentials require: The original choice of Ed25519 must be revised based on regulatory requirements: **Why ES256 must be the primary algorithm:** + - **EUDI HAIP mandatory:** "Issuers, Verifiers, and Wallets MUST, at a minimum, support ECDSA with P-256 and SHA-256 (ES256)" - **EdDSA deprecated:** RFC 9864 deprecates the `EdDSA` algorithm identifier in JOSE. The `joserfc` library already emits security warnings. - **Gaia-X compatible:** Gaia-X requires RFC 7518 compliant algorithms; ES256 qualifies - **X.509 ecosystem:** P-256 has universal support in certificate authorities and HSMs **Why Ed25519 should still be supported:** + - Existing test fixtures use Ed25519 - `did:key:z6Mk...` identifiers are Ed25519-based - Some Gaia-X implementations still use EdDSA @@ -43,6 +46,7 @@ The original choice of Ed25519 must be revised based on regulatory requirements: ### Key Format: JWK (RFC 7517) **ES256 key:** + ```json { "kty": "EC", @@ -53,6 +57,7 @@ The original choice of Ed25519 must be revised based on regulatory requirements: ``` **Ed25519 key:** + ```json { "kty": "OKP", @@ -72,18 +77,21 @@ Three mechanisms, serving different ecosystems: | **did:key** | Testing | `kid` | `did:key:zDn...#zDn...` | **X.509 (EUDI mandatory):** + - HAIP: "The public key MUST be included in the `x5c` JOSE header parameter" - Certificate chain from issuer to trust anchor (e.g., eIDAS qualified certificate) - Trust anchor certificate excluded from chain - No self-signed end-entity certificates **did:ethr (Gaia-X):** + - Resolves to DID Document at well-known URL with KERI key history - DID Document contains JWK public key(s) - Used for all Harbour identities (infrastructure, organizations, users) - Gaia-X GXDCH uses X.509 certificates as trust anchors for DIDs **did:key (testing):** + - Public key encoded directly in identifier - No network resolution needed - `did:key:zDn...` for P-256, `did:key:z6Mk...` for Ed25519 diff --git a/docs/decisions/005-did-ethr-migration.md b/docs/decisions/005-did-ethr-migration.md index f7749c2..17b9585 100644 --- a/docs/decisions/005-did-ethr-migration.md +++ b/docs/decisions/005-did-ethr-migration.md @@ -2,7 +2,7 @@ ## Status -**Accepted** +**Status:** Accepted ## Context @@ -16,6 +16,7 @@ This creates dependencies on DNS, TLS certificate authorities, and hosting avail conflict with the project's goal of decentralised, self-sovereign identity. The ENVITED-X ecosystem requires: + 1. Decentralised identity anchoring without web server dependencies 2. Verifiable key rotation history 3. P-256 key support (for EUDI/HAIP compliance) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 825ddcf..b7e4bed 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -49,18 +49,18 @@ npm run build ## Verify Installation -=== "Python" +**Python:** - ```bash - python -m harbour.keys --help - make test - ``` +```bash +python -m harbour.keys --help +make test +``` -=== "TypeScript" +**TypeScript:** - ```bash - npm test - ``` +```bash +npm test +``` ## Dependencies diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index e0ae952..62a3be7 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -4,121 +4,121 @@ This guide gets you signing and verifying credentials in minutes. ## 1. Generate a Keypair -=== "Python" +**Python:** - ```python - from harbour.keys import generate_p256_keypair, p256_public_key_to_did_key +```python +from harbour.keys import generate_p256_keypair, p256_public_key_to_did_key - private_key, public_key = generate_p256_keypair() - did = p256_public_key_to_did_key(public_key) - print(f"DID: {did}") - ``` +private_key, public_key = generate_p256_keypair() +did = p256_public_key_to_did_key(public_key) +print(f"DID: {did}") +``` -=== "TypeScript" +**TypeScript:** - ```typescript - import { generateP256Keypair, p256PublicKeyToDid } from '@reachhaven/harbour-credentials'; +```typescript +import { generateP256Keypair, p256PublicKeyToDid } from '@reachhaven/harbour-credentials'; - const { privateKey, publicKey } = await generateP256Keypair(); - const did = await p256PublicKeyToDid(publicKey); - console.log(`DID: ${did}`); - ``` +const { privateKey, publicKey } = await generateP256Keypair(); +const did = await p256PublicKeyToDid(publicKey); +console.log(`DID: ${did}`); +``` -=== "CLI" +**CLI:** - ```bash - python -m harbour.keys generate --curve p256 --output keypair.json - ``` +```bash +python -m harbour.keys generate --curve p256 --output keypair.json +``` ## 2. Sign a Credential -=== "Python" +**Python:** - ```python - from harbour.signer import sign_vc_jose +```python +from harbour.signer import sign_vc_jose - credential = { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiableCredential"], - "issuer": did, - "credentialSubject": { - "id": "did:example:subject", - "name": "Alice" - } +credential = { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["VerifiableCredential"], + "issuer": did, + "credentialSubject": { + "id": "did:example:subject", + "name": "Alice" } +} - jwt = sign_vc_jose(credential, private_key, kid=f"{did}#{did.split(':')[-1]}") - print(jwt) - ``` +jwt = sign_vc_jose(credential, private_key, kid=f"{did}#{did.split(':')[-1]}") +print(jwt) +``` -=== "TypeScript" +**TypeScript:** - ```typescript - import { signJwt } from '@reachhaven/harbour-credentials'; +```typescript +import { signJwt } from '@reachhaven/harbour-credentials'; - const credential = { - '@context': ['https://www.w3.org/ns/credentials/v2'], - type: ['VerifiableCredential'], - issuer: did, - credentialSubject: { - id: 'did:example:subject', - name: 'Alice' - } - }; +const credential = { + '@context': ['https://www.w3.org/ns/credentials/v2'], + type: ['VerifiableCredential'], + issuer: did, + credentialSubject: { + id: 'did:example:subject', + name: 'Alice' + } +}; - const jwt = await signJwt(credential, privateKey); - console.log(jwt); - ``` +const jwt = await signJwt(credential, privateKey); +console.log(jwt); +``` ## 3. Verify a Credential -=== "Python" +**Python:** - ```python - from harbour.verifier import verify_vc_jose +```python +from harbour.verifier import verify_vc_jose - result = verify_vc_jose(jwt, public_key) - print(f"Verified: {result['credentialSubject']['name']}") - ``` +result = verify_vc_jose(jwt, public_key) +print(f"Verified: {result['credentialSubject']['name']}") +``` -=== "TypeScript" +**TypeScript:** - ```typescript - import { verifyJwt } from '@reachhaven/harbour-credentials'; +```typescript +import { verifyJwt } from '@reachhaven/harbour-credentials'; - const result = await verifyJwt(jwt, publicKey); - console.log(`Verified: ${result.credentialSubject.name}`); - ``` +const result = await verifyJwt(jwt, publicKey); +console.log(`Verified: ${result.credentialSubject.name}`); +``` ## 4. Selective Disclosure (SD-JWT) -=== "Python" +**Python:** - ```python - from harbour.sd_jwt import issue_sd_jwt_vc, verify_sd_jwt_vc +```python +from harbour.sd_jwt import issue_sd_jwt_vc, verify_sd_jwt_vc - # Issue with selective disclosure - sd_jwt = issue_sd_jwt_vc( - credential, - private_key, - disclosable_claims=["name", "email"] - ) +# Issue with selective disclosure +sd_jwt = issue_sd_jwt_vc( + credential, + private_key, + disclosable_claims=["name", "email"] +) - # Verify - result = verify_sd_jwt_vc(sd_jwt, public_key) - ``` +# Verify +result = verify_sd_jwt_vc(sd_jwt, public_key) +``` -=== "TypeScript" +**TypeScript:** - ```typescript - import { issueSdJwt, verifySdJwt } from '@reachhaven/harbour-credentials'; +```typescript +import { issueSdJwt, verifySdJwt } from '@reachhaven/harbour-credentials'; - const sdJwt = await issueSdJwt(credential, privateKey, { - disclosableClaims: ['name', 'email'] - }); +const sdJwt = await issueSdJwt(credential, privateKey, { + disclosableClaims: ['name', 'email'] +}); - const result = await verifySdJwt(sdJwt, publicKey); - ``` +const result = await verifySdJwt(sdJwt, publicKey); +``` ## Next Steps diff --git a/docs/guide/delegated-signing.md b/docs/guide/delegated-signing.md index 140d98c..0546eed 100644 --- a/docs/guide/delegated-signing.md +++ b/docs/guide/delegated-signing.md @@ -22,7 +22,7 @@ The key innovation is **cryptographic proof of consent** — the user's VP serve ## How It Works -``` +```text User Signing Service Blockchain | | | | 1. Request transaction | | @@ -137,6 +137,7 @@ The signing service creates an OID4VP-aligned transaction data object (see [Dele ``` Naming note: + - `transaction_data` and `credential_ids` are OID4VP-defined snake_case fields. - `txn` is profile-defined payload; Harbour v1 standardizes snake_case keys such as `asset_id`. diff --git a/docs/index.md b/docs/index.md index 34fd4cc..2c62531 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,55 +13,55 @@ ## Quick Start -=== "Python" +**Python:** - ```python - from harbour.keys import generate_p256_keypair, p256_public_key_to_did_key - from harbour.signer import sign_vc_jose - from harbour.verifier import verify_vc_jose +```python +from harbour.keys import generate_p256_keypair, p256_public_key_to_did_key +from harbour.signer import sign_vc_jose +from harbour.verifier import verify_vc_jose - # Generate keypair - private_key, public_key = generate_p256_keypair() - did = p256_public_key_to_did_key(public_key) +# Generate keypair +private_key, public_key = generate_p256_keypair() +did = p256_public_key_to_did_key(public_key) - # Sign a credential - credential = {"type": ["VerifiableCredential"], "issuer": did, ...} - jwt = sign_vc_jose(credential, private_key) +# Sign a credential +credential = {"type": ["VerifiableCredential"], "issuer": did, ...} +jwt = sign_vc_jose(credential, private_key) - # Verify - result = verify_vc_jose(jwt, public_key) - ``` +# Verify +result = verify_vc_jose(jwt, public_key) +``` -=== "TypeScript" +**TypeScript:** - ```typescript - import { generateP256Keypair, p256PublicKeyToDid, signJwt, verifyJwt } from '@reachhaven/harbour-credentials'; +```typescript +import { generateP256Keypair, p256PublicKeyToDid, signJwt, verifyJwt } from '@reachhaven/harbour-credentials'; - // Generate keypair - const { privateKey, publicKey } = await generateP256Keypair(); - const did = await p256PublicKeyToDid(publicKey); +// Generate keypair +const { privateKey, publicKey } = await generateP256Keypair(); +const did = await p256PublicKeyToDid(publicKey); - // Sign a credential - const credential = { type: ['VerifiableCredential'], issuer: did, ... }; - const jwt = await signJwt(credential, privateKey); +// Sign a credential +const credential = { type: ['VerifiableCredential'], issuer: did, ... }; +const jwt = await signJwt(credential, privateKey); - // Verify - const result = await verifyJwt(jwt, publicKey); - ``` +// Verify +const result = await verifyJwt(jwt, publicKey); +``` ## Installation -=== "Python" +**Python:** - ```bash - pip install harbour-credentials - ``` +```bash +pip install harbour-credentials +``` -=== "TypeScript" +**TypeScript:** - ```bash - npm install @reachhaven/harbour-credentials - ``` +```bash +npm install @reachhaven/harbour-credentials +``` ## Documentation diff --git a/docs/specs/delegation-challenge-encoding.md b/docs/specs/delegation-challenge-encoding.md index 96af0a2..ae259c3 100644 --- a/docs/specs/delegation-challenge-encoding.md +++ b/docs/specs/delegation-challenge-encoding.md @@ -36,18 +36,19 @@ This separation is critical for QR code flows where the signed proof must be com The `proof.challenge` field uses a compact, single-line format: -``` +```text HARBOUR_DELEGATE ``` Where: + - `` is a unique identifier (hex string, min 8 chars) - `HARBOUR_DELEGATE` is the action type identifier - `` is the lowercase hex-encoded SHA-256 hash of the transaction data ### 2.2 Example -``` +```text da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52 ``` @@ -176,7 +177,8 @@ def compute_transaction_hash(transaction_data: dict) -> str: ``` The resulting challenge: -``` + +```text da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52 ``` @@ -249,7 +251,7 @@ This specification is designed for seamless integration with [OpenID for Verifia ### 5.1 Request Flow -``` +```text ┌─────────┐ ┌─────────┐ ┌─────────┐ │ Verifier│ │ Wallet │ │ Signing │ │(Service)│ │ (User) │ │ Service │ @@ -424,7 +426,7 @@ Following the design philosophy of [SIWE (EIP-4361)](https://eips.ethereum.org/E ### 9.1 Display Format -``` +```text ╔═══════════════════════════════════════════════════════════════════════╗ ║ Harbour Signing Service requests your authorization ║ ╠═══════════════════════════════════════════════════════════════════════╣ @@ -498,6 +500,7 @@ console.log(renderTransactionDisplay(tx)); These examples use the shared test vectors from `tests/fixtures/canonicalization-vectors.json`. **Transaction Data:** + ```json { "type": "harbour_delegate:data.purchase", @@ -515,13 +518,15 @@ These examples use the shared test vectors from `tests/fixtures/canonicalization ``` **Challenge:** -``` + +```text da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52 ``` ### 10.2 Blockchain Transfer Transaction **Transaction Data:** + ```json { "type": "harbour_delegate:blockchain.transfer", @@ -539,13 +544,15 @@ da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba ``` **Challenge:** -``` + +```text ef567890 HARBOUR_DELEGATE 0736db89c15be412294f96717a3e435f89d095e7e953b1808c422252b845d4c1 ``` ### 10.3 Contract Signature Transaction **Transaction Data:** + ```json { "type": "harbour_delegate:contract.sign", @@ -563,7 +570,8 @@ ef567890 HARBOUR_DELEGATE 0736db89c15be412294f96717a3e435f89d095e7e953b1808c4222 ``` **Challenge:** -``` + +```text ab12cd34 HARBOUR_DELEGATE 0863ac13bc5f15c7dfcdee71b8beea1aead4b822d0a7c03154405da4f192af08 ``` @@ -603,6 +611,7 @@ This specification aligns with [OID4VP Transaction Data (§8.4)](https://openid. ### Integration Example OID4VP authorization request: + ```json { "response_type": "vp_token", diff --git a/docs/specs/did-method-evaluation.md b/docs/specs/did-method-evaluation.md index a27260c..340a091 100644 --- a/docs/specs/did-method-evaluation.md +++ b/docs/specs/did-method-evaluation.md @@ -43,7 +43,7 @@ ## DID Format -``` +```text did:ethr::
# Base Sepolia Testnet (development) diff --git a/docs/specs/references/README.md b/docs/specs/references/README.md index 9f5ba6f..5bdd7e0 100644 --- a/docs/specs/references/README.md +++ b/docs/specs/references/README.md @@ -12,19 +12,24 @@ They are copies of specifications published by their respective standards organi | File | Source | Organization | License | |------|--------|--------------|---------| -| `oid4vp-1.0.txt` | [OpenID4VP 1.0](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html) | OpenID Foundation | [OpenID IPR](https://openid.net/intellectual-property/) | +| `vc-data-model-2.0.md` | [W3C VC Data Model v2.0](https://www.w3.org/TR/vc-data-model-2.0/) | W3C | [W3C Document License](https://www.w3.org/copyright/document-license-2023/) | +| `did-core.md` | [W3C DIDs v1.0](https://www.w3.org/TR/did-core/) | W3C | [W3C Document License](https://www.w3.org/copyright/document-license-2023/) | | `vc-jose-cose.md` | [VC-JOSE-COSE](https://www.w3.org/TR/vc-jose-cose/) | W3C | [W3C Document License](https://www.w3.org/copyright/document-license-2023/) | -| `sd-jwt-vc.md` | [SD-JWT-VC draft-14](https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/) | IETF | [IETF Trust](https://trustee.ietf.org/license-info) | +| `sd-jwt-rfc9901.md` | [RFC 9901: SD-JWT](https://www.rfc-editor.org/rfc/rfc9901) | IETF | [IETF Trust](https://trustee.ietf.org/license-info) | +| `sd-jwt-vc.md` | [SD-JWT-VC draft-15](https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/) | IETF | [IETF Trust](https://trustee.ietf.org/license-info) | +| `oid4vp-1.0.md` | [OpenID4VP 1.0](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html) | OpenID Foundation | [OpenID IPR](https://openid.net/intellectual-property/) | +| `oid4vp-1.0.txt` | Raw full spec text (3,834 lines) — retained for search | OpenID Foundation | [OpenID IPR](https://openid.net/intellectual-property/) | +| `gx-architecture-document-25.11.md` | [Gaia-X AD 25.11](https://docs.gaia-x.eu/technical-committee/architecture-document/25.11/) | Gaia-X AISBL | CC BY-NC-ND 4.0 | | `csc-data-model.md` | [CSC Data Model v1.0.0](https://cloudsignatureconsortium.org/wp-content/uploads/2025/10/data-model-bindings.pdf) | Cloud Signature Consortium | CSC License | -| `did-web-method.txt` | [did:web Specification](https://w3c-ccg.github.io/did-method-web/) *(superseded by did:ethr)* | W3C CCG | [W3C Document License](https://www.w3.org/copyright/document-license-2023/) | | `did-ethr-method-spec.md` | [did:ethr Method Specification](https://github.com/decentralized-identity/ethr-did-resolver/blob/master/doc/did-method-spec.md) | DIF | Apache-2.0 | -| `did-webs-spec.md` | [did:ethr Specification](https://github.com/trustoverip/tswg-did-method-webs-specification) | Trust Over IP Foundation | [ToIP License](https://github.com/trustoverip/tswg-did-method-webs-specification/blob/main/LICENSE.md) | +| `did-webs-spec.md` | [did:webs Specification](https://github.com/trustoverip/tswg-did-method-webs-specification) | Trust Over IP Foundation | [ToIP License](https://github.com/trustoverip/tswg-did-method-webs-specification/blob/main/LICENSE.md) | | `keri-draft.md` | [KERI Draft](https://github.com/WebOfTrust/ietf-keri) | WebOfTrust / IETF | Apache 2.0 | ## Download Date -- `oid4vp-1.0.txt`, `did-web-method.txt`, `did-webs-spec.md`, `keri-draft.md`: **2026-02-24** +- `oid4vp-1.0.txt`, `did-webs-spec.md`, `keri-draft.md`: **2026-02-24** - `vc-jose-cose.md`, `sd-jwt-vc.md`, `csc-data-model.md`: **2026-02-25** +- `oid4vp-1.0.md`, `vc-data-model-2.0.md`, `did-core.md`, `sd-jwt-rfc9901.md`, `gx-architecture-document-25.11.md`: **2026-03-10** ## Usage @@ -43,10 +48,6 @@ To update these references: curl -sL "https://openid.net/specs/openid-4-verifiable-presentations-1_0.html" | \ python3 -c "..." > oid4vp-1.0.txt -# did:web *(superseded by did:ethr)* -curl -sL "https://w3c-ccg.github.io/did-method-web/" | \ - python3 -c "..." > did-web-method.txt - # did:ethr (from GitHub) # See download script in repository @@ -59,16 +60,17 @@ curl -sL "https://raw.githubusercontent.com/WebOfTrust/ietf-keri/main/draft-ssmi Always refer to the original sources for the most up-to-date and legally binding versions: -- **OpenID4VP**: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html -- **did:web** *(superseded)*: https://w3c-ccg.github.io/did-method-web/ -- **did:ethr**: https://github.com/decentralized-identity/ethr-did-resolver/blob/master/doc/did-method-spec.md -- **did:ethr**: https://trustoverip.github.io/tswg-did-method-webs-specification/ -- **KERI**: https://weboftrust.github.io/ietf-keri/draft-ssmith-keri.html -- **W3C DID Core**: https://www.w3.org/TR/did-core/ - **W3C VC Data Model**: https://www.w3.org/TR/vc-data-model-2.0/ +- **W3C DID Core**: https://www.w3.org/TR/did-core/ - **W3C VC-JOSE-COSE**: https://www.w3.org/TR/vc-jose-cose/ -- **SD-JWT-VC**: https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/ - **SD-JWT (RFC 9901)**: https://www.rfc-editor.org/rfc/rfc9901 +- **SD-JWT-VC**: https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/ +- **OpenID4VP**: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html +- **Gaia-X Architecture**: https://docs.gaia-x.eu/technical-committee/architecture-document/25.11/ +- **did:ethr**: https://github.com/decentralized-identity/ethr-did-resolver/blob/master/doc/did-method-spec.md +- **did:web** *(superseded)*: https://w3c-ccg.github.io/did-method-web/ +- **did:webs**: https://trustoverip.github.io/tswg-did-method-webs-specification/ +- **KERI**: https://weboftrust.github.io/ietf-keri/draft-ssmith-keri.html - **CSC Data Model**: https://cloudsignatureconsortium.org/resources/ ## Disclaimer diff --git a/docs/specs/references/csc-data-model.md b/docs/specs/references/csc-data-model.md index 843e2c0..df46476 100644 --- a/docs/specs/references/csc-data-model.md +++ b/docs/specs/references/csc-data-model.md @@ -13,12 +13,14 @@ CSC API layer with the OID4VP credential presentation layer. ## Key Concepts ### Signature Request Flow + 1. Relying party creates a `signatureRequest` via CSC API 2. QTSP triggers OID4VP Authorization Request with `transaction_data` 3. Wallet presents credentials + KB-JWT with `transaction_data_hashes` 4. QTSP uses authorized credentials to produce QES ### Data Model Mapping to Harbour + | CSC Concept | Harbour Equivalent | OID4VP | |-------------|-------------------|--------| | `signatureRequest` | `transaction_data` | `transaction_data` (request param) | @@ -28,18 +30,22 @@ CSC API layer with the OID4VP credential presentation layer. | `SAD` (Signature Activation Data) | OID4VP consent flow | KB-JWT binding | ### SCAL2 Requirement + For SCAL2 (Sole Control Assurance Level 2), the authorization MUST be cryptographically bound to the specific document hashes being signed. This maps to OID4VP `transaction_data` with hash-bound consent. ### Integration with OID4VP + CSC-DM defines OID4VP `transaction_data` objects with: + - `type`: action type (e.g., `"sign"`) - `documentDigests`: array of document hashes - `hashAlgorithmOID`: hash algorithm (OID format in CSC, IANA name in OID4VP) - `credentialID`: identifies the signing credential at the QTSP ## Relationship to Other Specs + - **OID4VP**: CSC uses OID4VP `transaction_data` for authorization - **RFC 9901**: KB-JWT carries `transaction_data_hashes` proving wallet consent - **eIDAS 2.0**: QES requirements drive SCAL2 hash-bound authorization diff --git a/docs/specs/references/did-core.md b/docs/specs/references/did-core.md new file mode 100644 index 0000000..867f736 --- /dev/null +++ b/docs/specs/references/did-core.md @@ -0,0 +1,80 @@ +# W3C Decentralized Identifiers (DIDs) v1.0 + +**Status:** W3C Recommendation +**URL:** https://www.w3.org/TR/did-core/ + +## Key Normative Requirements + +### DID Syntax (§3.1) + +- A DID is a simple URI: `did::` +- DID URLs extend DIDs with path, query, and fragment components. +- DIDs MUST conform to the ABNF grammar defined in the specification. + +### DID Subject (§5.1.1) + +- Every DID document MUST have an `id` property. +- The value MUST be the DID that the document describes. + +### DID Controller (§5.1.2) + +- `controller` — OPTIONAL. A URI or set of URIs identifying the entity + authorized to make changes to the DID document. +- When present, value MUST be a string or an ordered set of strings, + each of which is a DID. + +### Verification Methods (§5.2) + +- `verificationMethod` — OPTIONAL. Array of verification method objects. +- Each verification method MUST have: `id`, `type`, `controller`. +- Key material MUST be expressed using `publicKeyJwk` or + `publicKeyMultibase` (§5.2.1). +- Multiple verification methods MAY be present. + +### Verification Relationships (§5.3) + +| Relationship | Purpose | Section | +|-------------|---------|---------| +| `authentication` | Prove DID controller identity | §5.3.1 | +| `assertionMethod` | Issue verifiable credentials | §5.3.2 | +| `keyAgreement` | Establish secure communication channels | §5.3.3 | +| `capabilityInvocation` | Invoke cryptographic capabilities | §5.3.4 | +| `capabilityDelegation` | Delegate capabilities to others | §5.3.5 | + +### Services (§5.4) + +- `service` — OPTIONAL. Array of service objects. +- Each service entry MUST have: `id`, `type`, `serviceEndpoint`. +- `serviceEndpoint` can be a URI, a map, or a set of these. +- Service values MUST be unique. + +### Representations (§6) + +- JSON (§6.2) and JSON-LD (§6.3) are specified representations. +- JSON-LD context: `https://www.w3.org/ns/did/v1` +- Media types: `application/did+json`, `application/did+ld+json` + +## Property Summary (Core) + +| Property | Requirement | Type | Section | +|----------|-------------|------|---------| +| `id` | MUST | DID URI | §5.1.1 | +| `controller` | OPTIONAL | DID or set of DIDs | §5.1.2 | +| `alsoKnownAs` | OPTIONAL | set of URIs | §5.1.3 | +| `verificationMethod` | OPTIONAL | array of objects | §5.2 | +| `authentication` | OPTIONAL | array of methods/refs | §5.3.1 | +| `assertionMethod` | OPTIONAL | array of methods/refs | §5.3.2 | +| `keyAgreement` | OPTIONAL | array of methods/refs | §5.3.3 | +| `capabilityInvocation` | OPTIONAL | array of methods/refs | §5.3.4 | +| `capabilityDelegation` | OPTIONAL | array of methods/refs | §5.3.5 | +| `service` | OPTIONAL | array of service objects | §5.4 | + +## Harbour Usage + +Harbour models a subset of DID Core: + +- `DIDDocument` class with `controller`, `service`, `verificationMethod` +- `VerificationMethod` class with `controller`, `blockchainAccountId` (extension) +- Service types: `TrustAnchorService`, `CRSetRevocationRegistryService`, + `LinkedCredentialService` +- DID method: `did:ethr` on Base L2 (see ADR-005) diff --git a/docs/specs/references/did-ethr-method-spec.md b/docs/specs/references/did-ethr-method-spec.md index 230dbc6..fed9c5b 100644 --- a/docs/specs/references/did-ethr-method-spec.md +++ b/docs/specs/references/did-ethr-method-spec.md @@ -430,21 +430,21 @@ A `DIDAttributeChanged` event for the account `0xf3beac30c498d9e26865f34fcaa57db With the exception of `#controller` and `#controllerKey`, the `id` properties that appear throughout the DID document MUST be stable across updates. This means that the same key material will be referenced by the same ID after an update. -* Attribute or delegate changes that result in `verificationMethod` entries MUST set the `id` +- Attribute or delegate changes that result in `verificationMethod` entries MUST set the `id` `${did}#delegate-${eventIndex}`. -* Attributes that result in `service` entries MUST set the `id` to `${did}#service-${eventIndex}` +- Attributes that result in `service` entries MUST set the `id` to `${did}#service-${eventIndex}` where `eventIndex` is the index of the event that modifies that section of the DID document. **Example** -* add key => `#delegate-1` is added -* add another key => `#delegate-2` is added -* add delegate => `#delegate-3` is added -* add service => `#service-1` ia added -* revoke first key => `#delegate-1` gets removed from the DID document; `#delegate-2` and `#delegte-3` remain. -* add another delegate => `#delegate-5` is added (earlier revocation is counted as an event) -* first delegate expires => `delegate-3` is removed, `#delegate-5` remains intact +- add key => `#delegate-1` is added +- add another key => `#delegate-2` is added +- add delegate => `#delegate-3` is added +- add service => `#service-1` ia added +- revoke first key => `#delegate-1` gets removed from the DID document; `#delegate-2` and `#delegte-3` remain. +- add another delegate => `#delegate-5` is added (earlier revocation is counted as an event) +- first delegate expires => `delegate-3` is removed, `#delegate-5` remains intact ### Update @@ -501,8 +501,8 @@ The `resolve` method returns an object with the following properties: `didDocume When resolving a DID document that has had updates, the latest update MUST be listed in the `didDocumentMetadata`. -* `versionId` MUST be the block number of the latest update. -* `updated` MUST be the ISO date string of the block time of the latest update (without sub-second resolution). +- `versionId` MUST be the block number of the latest update. +- `updated` MUST be the ISO date string of the block time of the latest update (without sub-second resolution). Example: @@ -525,7 +525,7 @@ Example: } ``` -## Resolving DID URIs with query parameters. +## Resolving DID URIs with query parameters ### `versionId` query string parameter @@ -539,8 +539,8 @@ Only ERC1056 events prior to or contained in this block number are to be conside If there are any events after that block that mutate the DID, the earliest of them SHOULD be used to populate the properties of the `didDocumentMetadata`: -* `nextVersionId` MUST be the block number of the next update to the DID document. -* `nextUpdate` MUST be the ISO date string of the block time of the next update (without sub-second resolution). +- `nextVersionId` MUST be the block number of the next update to the DID document. +- `nextUpdate` MUST be the ISO date string of the block time of the next update (without sub-second resolution). In case the DID has had updates prior to or included in the `versionId` block number, the `updated` and `versionId` properties of the `didDocumentMetadata` MUST correspond to the latest block prior to the `versionId` query string param. diff --git a/docs/specs/references/did-web-method.txt b/docs/specs/references/did-web-method.txt deleted file mode 100644 index 8bbb8c3..0000000 --- a/docs/specs/references/did-web-method.txt +++ /dev/null @@ -1,429 +0,0 @@ - - - did:web Method Specification - - DIDs that target a distributed ledger face significant practical - challenges in bootstrapping enough meaningful trusted data around - identities to incentivize mass adoption. - We propose a new DID method using a web domain's existing reputation. - - Introduction - - Preface - - The Web DID method specification conforms to the requirements specified - in the Decentralized Identifiers v1.0 Specification [[DID-CORE]]. For - more information about DIDs and DID method specifications, please also - see the [[?DID-PRIMER]] - - Examples - -{ - "@context": [ - "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/suites/jws-2020/v1" - ], - "id": "did:web:example.com", - "verificationMethod": [ - { - "id": "did:web:example.com#key-0", - "type": "JsonWebKey2020", - "controller": "did:web:example.com", - "publicKeyJwk": { - "kty": "OKP", - "crv": "Ed25519", - "x": "0-e2i2_Ua1S5HbTYnVB0lj2Z2ytXu2-tYmDFf8f5NjU" - } - }, - { - "id": "did:web:example.com#key-1", - "type": "JsonWebKey2020", - "controller": "did:web:example.com", - "publicKeyJwk": { - "kty": "OKP", - "crv": "X25519", - "x": "9GXjPGGvmRq9F6Ng5dQQ_s31mfhxrcNZxRGONrmH30k" - } - }, - { - "id": "did:web:example.com#key-2", - "type": "JsonWebKey2020", - "controller": "did:web:example.com", - "publicKeyJwk": { - "kty": "EC", - "crv": "P-256", - "x": "38M1FDts7Oea7urmseiugGW7tWc3mLpJh6rKe7xINZ8", - "y": "nDQW6XZ7b_u2Sy9slofYLlG03sOEoug3I0aAPQ0exs4" - } - }, - ], - "authentication": [ - "did:web:example.com#key-0", - "did:web:example.com#key-2" - ], - "assertionMethod": [ - "did:web:example.com#key-0", - "did:web:example.com#key-2" - ], - "keyAgreement": [ - "did:web:example.com#key-1", - "did:web:example.com#key-2" - ] -} - -{ - "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/suites/secp256k1recovery-2020/v2"], - "id": "did:web:example.com", - "verificationMethod": [{ - "id": "did:web:example.com#address-0", - "type": "EcdsaSecp256k1RecoveryMethod2020", - "controller": "did:web:example.com", - "blockchainAccountId": "eip155:1:0x89a932207c485f85226d86f7cd486a89a24fcc12" - }], - "authentication": [ - "did:web:example.com#address-0" - ] -} - - Web DID Method Specification - - Target system - - The target system of the Web DID method is the host (or domain if the - host - is not specified) name when the domain specified by the DID is resolved - through the Domain Name System (DNS). - - Method name - - The namestring that shall identify this DID method is: web. - - A DID that uses this method MUST begin with the following prefix: - did:web. Per the DID specification, this string MUST be in - lowercase. The remainder of the DID, after the prefix, is specified - below. - - Method-specific identifier - - The method specific identifier is a fully qualified domain name that is - secured by a TLS/SSL certificate with an optional path to the DID - document. The formal rules describing valid domain name syntax are - described in [[RFC1035]], [[RFC1123]], and [[RFC2181]]. - - The method specific identifier MUST match the common name used in the - SSL/TLS certificate, and it MUST NOT include IP addresses. A port MAY be - included and the colon MUST be percent encoded to prevent a conflict - with paths. Directories and subdirectories MAY optionally be included, - delimited by colons rather than slashes. - -web-did = "did:web:" domain-name -web-did = "did:web:" domain-name * (":" path) - -did:web:w3c-ccg.github.io - -did:web:w3c-ccg.github.io:user:alice - -did:web:example.com%3A3000 - - Key Material and Document Handling - - Due to the way most web servers present content, it is likely that a - particular `did:web` document will be served with a media type of - `application/json`. If a document is retrieved and it is named - `did.json`, a few processing rules should apply: - - If an - `@context` is present at the root of the JSON document, - the document should be processed according to the JSON-LD rules. - If this is not possible, or if the document fails processing, the - document should be rejected from consideration as a `did:web` doc. - - If an - `@context` is present at the root of the JSON document, - and it passes JSON-LD processing, and it contains the context - `https://www.w3.org/ns/did/v1`, it may be further processed - as a - DID document as specified by section - 6.3.2 of - the - [[did-core]] specification. - - If no - `@context` is present, it should be processed via normal - JSON rules for DID processing as specified in section - 6.2.2 of the - [[did-core]] specification. - - Whenever a DID URL is present within a `did:web` document, it must - be an absolute URL. - - This includes URLs inside of embedded key material and other metadata, and - prevents - key confusion attacks. - - DID method operations - - There is intentionally no HTTP API specified for did:web method - operations leaving programmatic registrations and management to be - defined by each implementation, or based on their own requirements in - their web environment. - - Create (Register) - - Creating a DID is done by: - - applying at a domain name registrar for use of a domain name and - - storing the location of a hosting service, the IP address at a DNS - lookup service - - creating the DID document JSON-LD file including a suitable keypair, - e.g. using the Koblitz Curve, and storing the did.json - file under the well-known URL to represent the entire domain, or - under the specified path if many DIDs will be resolved in this - domain. - - For example, for the domain name `w3c-ccg.github.io`, the `did.json` - will be available under the following URL: - -did:web:w3c-ccg.github.io - -> https://w3c-ccg.github.io/.well-known/did.json - - If an optional path is specified rather the bare domain, the - did.json will be available under the specified path: - -did:web:w3c-ccg.github.io:user:alice - -> https://w3c-ccg.github.io/user/alice/did.json - - If an optional port is specified on the domain, the port colon - splitting the host and the port MUST be percent encoded to prevent - collision with the path. - -did:web:example.com%3A3000:user:alice - -> https://example.com:3000/user/alice/did.json - - Read (Resolve) - - The following steps MUST be executed to resolve the DID document from - a Web DID: - - Replace ":" with "/" in the method specific identifier to obtain the - fully qualified domain name and optional path. - - If the domain contains a port percent decode the colon. - - Generate an HTTPS URL to the expected location of the DID document - by prepending https://. - - If no path has been specified in the URL, append - /.well-known. - - Append /did.json to complete the URL. - - Perform an HTTP GET request to the URL using an agent - that can successfully negotiate a secure HTTPS connection, which - enforces the security requirements as described in . - - Verify that the ID of the resolved DID document matches the Web DID being resolved. - - When performing the DNS resolution during the HTTP GET - request, the client SHOULD utilize [[RFC8484]] in order to prevent - tracking of the identity being resolved. - - Update - - To update the DID document, the did.json has to be - updated. Please note that the DID will remain the same, but the - contents of the DID document could change, e.g., by including a new - verification key or adding service endpoints. - - Managing updates to the DID Document using a version control system - such as git and continious integration system such as GitHub Actions - can provide support for authentication and audit history. - - There is no HTTP API specified for the update process leaving - programmatic registrations and management to be defined by each - implementation. - - Deactivate (Revoke) - - To delete the DID document, the did.json has to be - removed or has to be no longer publicly available due to any other - means. - - Security and privacy considerations - - Authentication and Authorization - - This DID method does not specify any authentication or authorization - mechanism for writing to, removing or creating the DID Document, - leaving it up to implementations to protect did:web documents as with - any other web resource. - - It is up to implementer to secure their web environments according to - industry best practices for updating or otherwise managing web content - based on the specific needs of their threat environment. - - DNS Considerations - - DNS Security Considerations - - DNS presents many of the attack vectors that enable active security - and privacy attacks on the did:web method and it's important that - implementors address these concerns via proper configuration of DNS. - For example, without proper security of the DNS resolution via DNS over HTTPS it's - possible for active attackers to intercept the result of the DNS - resolution via a Man in the Middle attack which would point at a - malicious server with the incorrect DID Document. - - Additionally, implementors should be aware of issues presented by a - Spoofed DNS records where the record returned by a malicious DNS - Server is inauthentic and allows the record to be pointed at a - malicious server which contains a different DID Document. To prevent - this type of issue, usage of DNSSEC which is - RFC4033, - RFC4034, and - RFC4035. - - DNS Privacy Considerations - - Due to the nature of the did:web method relying upon a DNS in order to - resolve the web server, all resolutions of a did:web identifier have - the potential to be tracked by a DNS provider. Additionally, due to - the DID Document being stored on a web server, each time the DID - Document resource is retrieved, the web server has the ability to - track the resolution of the DID Document. To mitigate the issue of the - relying party being tracked when resolving the DID Document the - relying party should look to either use a trusted universal resolver - service to gain herd privacy, utilize a VPN service or perform a - resolution over the TOR network. Another emerging solution that will - be useful to address this is - draft-pauly-dprive-oblivious-doh-03 - - DID Document Integrity Verification - - Additional mechanisms such as Hashlinks - MAY be utilized to aid in integrity protection and verification of the - DID document. - - Under such a scenario the hash of the DID document could be recorded - to a trusted or distributed store and the retriever of the DID - document would generate a hash of the DID document in their posession - with the hash retrieved to ensure that no tampering with the DID - document had occurred. - - In-transit Security - - Guidance from - NIST SP 800-52 Rev. 2 - or superceding, MUST be followed for delivery of a `did:web` - document. - - It is additionally recommended to adhere to the latest recommendations - from OWASP's Transport Layer Protection Cheat Sheet [[OWASP-TRANSPORT]] - for hardening TLS configurations. - - Consult - NIST SP 800-57 - for guidance on cryptoperiod, which is the time span during which - a specific key is authorized for use or in which the keys for a given - system or application may remain in effect. - - TLS configuration MUST use at least SHA256, and SHOULD use SHA384, - POLY1305, or stronger, depending on the needs of your - operating environment. - - Delete action MAY be performed by domain name registrars or DNS lookup - services. - - As of this writing, TLS 1.2 or higher SHOULD be configured to use - only strong ciphers suites and to use sufficiently large key sizes. - As recommendations may be volatile these days, only the very latest - recommendations should be used. However, as a rule of thumb, - the following set of suites is a reasonable starting point: - - ECDHE with one of the strong curves {X25519, brainpoolP384r1, NIST - P-384, brainpoolP256r1, NIST P-256} shall be used as key exchange. - - AESGCM or ChaCha20 with 256 bit large keys shall be used for bulk - encryption - - ECDSA with one of the strong curves {brainpoolP384r1, NIST P-384, - brainpoolP256r1, NIST P-256} or RSA (at least 3072) shall be used. - - Authenticated Encryption with Associated Data (AEAD) shall be used - as Mac. - - Examples of strong SSL/TLS configurations for now are: - - ECDHE-ECDSA-AES256-GCM-SHA384, TLSv1.2, Kx=ECDH, Au=ECDSA, Enc=AESGCM(256), Mac=AEAD - - ECDHE-RSA-AES256-GCM-SHA384, TLSv1.2, Kx=ECDH, Au=RSA Enc=AESGCM(256), Mac=AEAD - - ECDHE-ECDSA-CHACHA20-POLY1305, TLSv1.2, Kx=ECDH, Au=ECDSA, Enc=ChaCha20-Poly1305, Mac=AEAD - - ECDHE-RSA-CHACHA20-POLY1305, TLSv1.2, Kx=ECDH, Au=RSA, Enc=ChaCha20-Poly1305, Mac=AEAD - - ECDHE-RSA-AES256-GCM-SHA384, TLSv1.2, Kx=ECDH, Au=RSA, Enc=AESGCM(256), Mac=AEAD - - ECDHE-ECDSA-AES256-GCM-SHA384, TLSv1.2, Kx=ECDH, Au=ECDSA, Enc=AESGCM(256), Mac=AEAD - - International Domain Names - - [[DID-CORE]] identifier syntax does not - allow Unicode in method name nor method specific identifiers. - - Implementers should be cautious when implementing - support for DID URLs that rely on domain names - or path components that contain Unicode characters. - - See also: - - UTS-46 - - IDNA 2008 - - Optional Path Considerations - - When optional paths to DID documents are used to resolve documents - rather than bare domains, verification with signed data proves that - the entity in control of the file indicated in the path has the - private keys. It does not prove that the domain operator has the - private keys. - - This example: - -did:web:example.com:u:bob - - resolves to the DID document at: - -https://example.com/u/bob/did.json - - In this scenario, it is probable that example.com has given user Bob - control over the DID in question, and proofs of control refer to Bob - rather than all of example.com. - - Cross-Origin Resource Sharing (CORS) Policy Considerations - - To support scenarios where DID resolution is performed by client - applications running in a web browser, the file served for the DID - document should be accessible by any origin. To enable this, - the DID document HTTP response can be set to include the - following header: - -Access-Control-Allow-Origin: * - - Reference implementations - - The code at uport-project/https-did-resolver - is intended to present a reference implementation of this DID method. Any - other implementations should ensure that they pass the test suite - described in /src/__tests__ before claiming compatibility. - - The code at transmute-industries/restricted-resolver - implements this specification. - - The code at reinkrul/java-did-resolvers - implements this specification as a Java library. - - diff --git a/docs/specs/references/did-webs-spec.md b/docs/specs/references/did-webs-spec.md index 68959e4..4caceab 100644 --- a/docs/specs/references/did-webs-spec.md +++ b/docs/specs/references/did-webs-spec.md @@ -6,6 +6,7 @@ Downloaded: 2026-02-24 --- ## abstract + ## Abstract This document specifies a [DID @@ -40,13 +41,15 @@ either a drawback or a benefit (or both). --- ## introduction + ## Introduction + ::: informative Introduction DID methods answer many questions. Two noteworthy ones are: -* How is information about DIDs (in the form of DID documents) published and discovered? -* How is the trustworthiness of this information evaluated? +* How is information about DIDs (in the form of DID documents) published and discovered? +* How is the trustworthiness of this information evaluated? The previously released `did:web` method merges these two questions, giving one answer: _Information is published and secured using familiar web mechanisms_. This has wonderful adoption benefits, because the processes and tooling are familiar to millions of developers. @@ -56,7 +59,7 @@ Furthermore, familiar web mechanisms are almost always operated by corporate IT The `did:webs` method described in this spec separates these two questions and answers them distinctively. _Information about DIDs_ is still published on the web, but its _trustworthiness_ derives from mechanisms entirely governed by individual DID controllers. This preserves most of the delightful convenience of `did:web`, while drastically upgrading security through authentic data that is end-verifiable. -Within the context of `did:webs` the term *decentralized trust* includes verifiability, confidentiality, and privacy, but excludes veracity of the content. The latter is always a matter of (personal) evaluation of available reputational data and verifiable credentials (VCs). +Within the context of `did:webs` the term _decentralized trust_ includes verifiability, confidentiality, and privacy, but excludes veracity of the content. The latter is always a matter of (personal) evaluation of available reputational data and verifiable credentials (VCs). As a preview of syntax, see the below sample did:webs DID: @@ -73,11 +76,12 @@ did:webs:example.com%3A3000:users:alice:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9 ::: - --- ## core + ## Core Characteristics + This section is normative. ### Method Name @@ -145,6 +149,7 @@ To be compatible with `did:web`, the AID is "just a path", the final (and perhap ::: ### Target System(s) + 1. As with `did:web`, `did:webs` MUST read data from whatever web server is referenced when the [[ref: host]] portion of one of its DID is resolved. 1. A `did:webs` DID MUST resolve to a [[ref: DID document]] using a simple text transformation to an HTTPS URL in the same way as a `did:web` DID. 1. A `did:web` DID and `did:webs` DID with the same [[ref: method-specific identifier]] SHOULD return the same DID document, except for minor differences in the `id`, `controller`, and `alsoKnownAs` top-level properties that pertain to the identifiers themselves. @@ -158,16 +163,18 @@ To be compatible with `did:web`, the AID is "just a path", the final (and perhap 1. MUST replace the trailing "`/did.json`" with "`/keri.cesr`". 2. A GET on that URL MUST return the KERI event stream for the AID in the `did:webs` identifier. 3. The KERI event stream MUST be [[ref: CESR]]-formatted (media type of application/cesr) and the KERI events must be verifiable using the KERI rules. -2. The `did:web` version of the DIDs MUST be the same (minus the `s`) and point to the same `did.json` file, but have no knowledge of the `keri.cesr` file. +1. The `did:web` version of the DIDs MUST be the same (minus the `s`) and point to the same `did.json` file, but have no knowledge of the `keri.cesr` file. ::: informative Target system and KERI verifiability For more information, see the following sections in the implementors guide: + * [the set of KERI features needed](#the-set-of-keri-features-needed) to support `did:webs` A target system cannot forge or tamper with data protected by KERI, and if it deliberately serves an outdated copy, the duplicity is often detectable. Thus, any given target system in isolation can be viewed by this method as a dumb, untrusted server of content. It is the combination of target systems and some KERI mechanisms, _together_, that constitutes this method's verifiable data registry. In short, verifying the DID document by processing the [[ref: KERI event stream]] using KERI puts the "s" of "security" in `did:webs`. The following are some example `did:webs` DIDs and their corresponding DID documents and KERI event stream URLs, based on the examples from the [[ref: did:web Specification]], but with the (faked) AID `12124313423525` added: + * `did:webs:w3c-ccg.github.io:12124313423525` * The DID document URL would look like: `https://w3c-ccg.github.io/12124313423525/did.json` * [[ref: KERI event stream]] URL would look like: `https://w3c-ccg.github.io/12124313423525/keri.cesr` @@ -181,11 +188,12 @@ The following are some example `did:webs` DIDs and their corresponding DID docum ::: ### AID controlled identifiers + 1. [[ref: AID controlled identifiers]] MAY vary in how quickly they reflect the current identity information, DID document and [[ref: KERI event stream]]. Notably, as defined in section [Stable Identifiers On An Unstable Web](#stable-identifiers-on-an-unstable-web), the `id` property in the DID document will differ based on the web location of the DID document. 1. Different versions of the DID document and KERI event stream MAY reside in different locations depending on the replication capabilities of the controlling entity. -2. If the KERI event streams differ for `did:webs` DIDs with the same AID, the smaller KERI event stream MUST be a prefix of the larger KERI event stream (e.g., the only difference in the [[ref: KERI event streams]] being the extra events in one of the KERI event streams, not yet reflected in the other). -3. If the KERI event streams diverge from one other (e.g., one is not a subset of the other), both the KERI event streams and the DIDs MUST be considered invalid. -4. The verification of the KERI event stream SHOULD provide mechanisms for detecting the forking of the KERI event stream by using mechanisms such as KERI witnesses and watchers. +1. If the KERI event streams differ for `did:webs` DIDs with the same AID, the smaller KERI event stream MUST be a prefix of the larger KERI event stream (e.g., the only difference in the [[ref: KERI event streams]] being the extra events in one of the KERI event streams, not yet reflected in the other). +1. If the KERI event streams diverge from one other (e.g., one is not a subset of the other), both the KERI event streams and the DIDs MUST be considered invalid. +1. The verification of the KERI event stream SHOULD provide mechanisms for detecting the forking of the KERI event stream by using mechanisms such as KERI witnesses and watchers. ::: informative AID and KERI event stream binding Since an AID is a unique cryptographic identifier that is inseparably bound to the [[ref: KERI event stream]] it is associated with any AIDs and any `did:webs` DIDs that have the same AID component. It can be verifiably proven that they have the same controller(s). @@ -197,7 +205,7 @@ Since an AID is a unique cryptographic identifier that is inseparably bound to t 1. When a `did:webs` DID is updated for another location the following rules MUST apply: 1. Its AID MUST not change. 1. The same [[ref: KERI event stream]] MUST be used to verify the DID document, with the only change being the [[ref: designated aliases]] list reflecting the new location identifier. - 1. If a resolver can find a newly named DID that uses the same AID, and the KERI event stream verifies the DID, then the resolver MAY consider the resolution to be successful and should note it in the resolution metadata. + 1. If a resolver can find a newly named DID that uses the same AID, and the KERI event stream verifies the DID, then the resolver MAY consider the resolution to be successful and should note it in the resolution metadata. 1. The following resolution paths that `did:webs` identfiers SHALL leverage to help in the face of resolution uncertainty includes: 1. The `did:webs` DID SHALL provide other [[ref: designated aliases]] DID(s) that are anchored to the [[ref: KERI event stream]]. @@ -237,7 +245,7 @@ An active component might be used by the controller of the DID to automate the p 1. MUST process the KERI event stream using [[ref: KERI Protocol]] Rules to verify it, then derive the `did:webs` [[ref: DID document]] by processing the KERI event stream according to section [DID Documents](#did-documents). 1. MUST transform the retrieved `did:web` DID document to the corresponding `did:webs` DID document according to section [Transformation to did:webs DID Document](#transformation-to-didwebs-did-document). 1. MUST verify that the derived `did:webs` DID document equals the transformed DID document. - 2. KERI-aware applications MAY use the KERI event stream to make use of additional capabilities enabled by the use of KERI. + 1. KERI-aware applications MAY use the KERI event stream to make use of additional capabilities enabled by the use of KERI. ::: informative Scope of KERI capabilities Capabilities beyond the verification of the DID document, the KERI event stream, and delegated identifiers are outside the scope of this specification. @@ -262,7 +270,9 @@ the [[ref: KERI event stream]]. --- ## keri + ## KERI Fundamentals + ::: informative KERI Fundamentals [[ref: Key Event Receipt Infrastructure)]] is a protocol for managing cryptographic keys, identifiers, and associated verifiable data structures. KERI was first described in an [academic paper](https://arxiv.org/abs/1907.02143), and its [specification](https://github.com/trustoverip/tswg-keri-specification) is currently incubated under [Trust Over IP Foundation](https://trustoverip.org/). The open source community that develops KERI-related technologies can be found at `https://github.com/WebOfTrust/keri`. This section outlines the fundamentals and components of the KERI protocol that are related to the `did:webs` method. @@ -308,7 +318,7 @@ Unlike a blockchain with a distributed consensus mechanism, witnesses do not coo ### Transaction Event Log (TEL) -The [[ref: KERI]] protocol supports a verifiable data structure, called the [[ref: transaction event log]], that binds an [[ref: AID]] to non-repudiable data that is deterministically bound to the [[ref: key event]] history in the [[ref: KEL]]. Transactions that are recorded in a TEL may include things like the issuance and revocation of verifiable credentials or the fact that listeners on various service endpoints started or stopped. Like KELs, TELs are self-certifying and may also be published by KERI witnesses to enhance discoverability and provide watcher networks the ability to detect duplicity. For example, we demonstrate that in this spec in how we anchor [[ref: designated aliases]] as [verifiable data on a TEL](#verifiable-data-on-a-tel). +The [[ref: KERI]] protocol supports a verifiable data structure, called the [[ref: transaction event log]], that binds an [[ref: AID]] to non-repudiable data that is deterministically bound to the [[ref: key event]] history in the [[ref: KEL]]. Transactions that are recorded in a TEL may include things like the issuance and revocation of verifiable credentials or the fact that listeners on various service endpoints started or stopped. Like KELs, TELs are self-certifying and may also be published by KERI witnesses to enhance discoverability and provide watcher networks the ability to detect duplicity. For example, we demonstrate that in this spec in how we anchor [[ref: designated aliases]] as [verifiable data on a TEL](#verifiable-data-on-a-tel). ### Web Independence @@ -318,23 +328,23 @@ Although _this DID method depends on web technology, KERI itself does not_. It's [[ref: KELs]] and [[ref: TELs]] of `did:webs` DIDs (i.e., AIDs) are included in the [[ref: KERI event streams]] for verification of the DID documents. The KERI event streams use [[ref: Composable Event Streaming Representation ([[ref: CESR]])]] for data serialization. Although CESR is a deep subject all by itself, at a high level, it has two essential properties: -* **Content in CESR is self-describing and supports serialization as binary and text**: That is in [[ref: CESR]], _a digital signature on a CESR data structure is stable no matter which underlying serialization format is used_. In effect it supports multiple popular serialization formats like JSON, CBOR, and MsgPack with room for many more. These formats can be freely mixed and combined in a CESR stream because of the self-describing nature of these individual CESR data structures. The practical effect is that developers get the best of both worlds: they can produce and consume data as text to display and debug in a human-friendly form and they can store and transmit this data in its tersest form, _all without changing the signature on the data structures_. -* **Cryptographic primitives are structured into compact standard representations**: Cryptographic primitives such as keys, hashes, digests, sealed-boxes, signatures, etc... are structured strings with a recognizable data type prefix and a standard representation in the stream. This means they are very terse and there is no need for the variety of representation methods that create interoperability challenges in other DID methods (`publicKeyJwk` versus `publicKeyMultibase` versus other; see the verification material section of the [[ref: DID specification]]. +* **Content in CESR is self-describing and supports serialization as binary and text**: That is in [[ref: CESR]], _a digital signature on a CESR data structure is stable no matter which underlying serialization format is used_. In effect it supports multiple popular serialization formats like JSON, CBOR, and MsgPack with room for many more. These formats can be freely mixed and combined in a CESR stream because of the self-describing nature of these individual CESR data structures. The practical effect is that developers get the best of both worlds: they can produce and consume data as text to display and debug in a human-friendly form and they can store and transmit this data in its tersest form, _all without changing the signature on the data structures_. +* **Cryptographic primitives are structured into compact standard representations**: Cryptographic primitives such as keys, hashes, digests, sealed-boxes, signatures, etc... are structured strings with a recognizable data type prefix and a standard representation in the stream. This means they are very terse and there is no need for the variety of representation methods that create interoperability challenges in other DID methods (`publicKeyJwk` versus `publicKeyMultibase` versus other; see the verification material section of the [[ref: DID specification]]. Despite this rich set of features, KERI imposes only light dependencies on developers. The cryptography it uses is familiar and battle-hardened, exactly what one would find in standard cryptography toolkits. For example, the python implementation (keripy) only depends on the `pysodium`, `blake3`, and `cryptography` python packages. Libraries for KERI exist in javascript, rust, and python. ::: - --- ## diddocuments + ## DID Documents This section is normative. 1. `did:webs` DID documents MUST be generated or derived from the [[ref: KERI event stream]] of the corresponding AID. - 1. Processing the KERI event stream of the AID, the generation algorithm MUST read the AID [[ref: KEL]] and any anchored [[ref: TELs]] to produce the DID document, including any designated alias ACDCs. + 1. Processing the KERI event stream of the AID, the generation algorithm MUST read the AID [[ref: KEL]] and any anchored [[ref: TELs]] to produce the DID document, including any designated alias ACDCs. 2. `did:webs` DID documents MUST be pure JSON. They MAY be processed as JSON-LD by prepending an `@context` if consumers of the documents wish. 3. All hashes, cryptographic keys, and signatures MUST be represented as [[ref: CESR]] strings. This is an approach similar to [multibase](https://github.com/multiformats/multibase), making them self-describing and terse. @@ -419,17 +429,17 @@ This section is normative. This section is normative. -1. The `alsoKnownAs` property in the root of the DID document MAY contain any DID that has the same AID. +1. The `alsoKnownAs` property in the root of the DID document MAY contain any DID that has the same AID. ::: informative designated aliases reference See the [[ref: designated aliases]] section for information on how an AID anchors the `alsoKnownAs` identifiers to their [[ref: KERI event stream]]. - ::: - 1. As long as the identifier is resolvable, a designated aliases ACDC containing a given identifier MUST always be present in the `keri.cesr` stream in order for any identifier to be included in the `alsoKnownAs` section of a `did:webs` DID document. + ::: + 1. As long as the identifier is resolvable, a designated aliases ACDC containing a given identifier MUST always be present in the `keri.cesr` stream in order for any identifier to be included in the `alsoKnownAs` section of a `did:webs` DID document. ::: informative transformation rules note Presence of designated alias ACDCs containing both `did:webs` and `did:web` identifiers are required to support the transformation rules between `did:webs` and `did:web` versions of a `did:webs` DID document while adhering to the security posture of KERI and ACDC. One potential way to implement this requirement is to ensure that resolving a given version of a `did:webs` DID document via the `versionId` parameter will return the DID document as of a given sequence number by analyzing the designated aliases ACDCs that were valid and unrevoked at that time. ::: - + 1. The `did:webs` version of the DID document MUST include the `did:web` version of the DID as an `alsoKnownAs` identifier, meaning it must also be in a valid, un-revoked designated aliases ACDC present in the `keri.cesr` stream. 1. The `did:web` version of the DID document MUST include the `did:webs` version of the DID as an `alsoKnownAs` identifier, meaning it must also be in a valid, un-revoked designated aliases ACDC present in the `keri.cesr` stream. 1. In order for the `did:webs` DID document to be valid, the `keri.cesr` stream MUST contain at least ONE designated aliases ACDC in which the DNS name and path for the `did:webs` identifier are committed to for both the `did:webs` and `did:web` versions of the identifier. @@ -438,10 +448,10 @@ This section is normative. This implies that the `did.json` for both the `did:webs` and `did:web` versions of a `did:webs` DID document will always contain a reciprocal link to one another that is also committed to by an event anchored into the KEL of the DID controller. - A consumer of a DID document can only know that a given `did:web` DID is trustable and committed to by the controller of the AID supporting a `did:webs` DID only when that `did:web` DID is included in an un-revoked designated aliases ACDC. - + A consumer of a DID document can only know that a given `did:web` DID is trustable and committed to by the controller of the AID supporting a `did:webs` DID only when that `did:web` DID is included in an un-revoked designated aliases ACDC. + This protects against DID document malleability attacks where a malicious DID resolver host could inject fraudulent `did:web` DIDs into a DID document. As such, the consumer of a `did:webs` DID document should only trust `did:web` DIDs that are found in an un-revoked designated aliases ACDC present in the `keri.cesr` stream. - ::: + ::: 1. `did:webs` DIDs MUST provide the corresponding `did:keri` as an `alsoKnownAs` identifier. 1. The same AID MAY be associated with multiple `did:webs` DIDs, each with a different [[ref: host]] and/or path, but with the same AID. 1. `did:webs` DIDs MUST be listed in the Designated aliases attestation of the AID. @@ -449,6 +459,7 @@ This section is normative. ::: informative example alsoKnownAs For the example DID `did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe` the following `alsoKnownAs` entries could be created: + ```json { "alsoKnownAs": [ @@ -460,6 +471,7 @@ For the example DID `did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW ] } ``` + ::: ### Verification Methods @@ -503,9 +515,11 @@ For example, the key `DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr` in the DID d ::: #### Ed25519 + 1. Ed25519 public keys MUST be converted to a verification method with a type of `JsonWebKey` and `publicKeyJwk` property whose value is generated by decoding the [[ref: CESR]] representation of the public key out of the KEL and into its binary form (minus the leading 'B' or 'D' CESR codes) and generating the corresponding representation of the key in JSON Web Key form. For example, a KERI AID with only the following inception event in its KEL: + ```json { "v":"KERI10JSON00012b_", @@ -518,7 +532,9 @@ For example, a KERI AID with only the following inception event in its KEL: // ... } ``` + would result in a DID document with the following verification methods array: + ```json "verificationMethod": [ { @@ -536,9 +552,11 @@ would result in a DID document with the following verification methods array: ``` #### Secp256k1 + 1. Secp256k1 public keys MUST be converted to a verification method with a type of `JsonWebKey` and `publicKeyJwk` property whose value is generated by decoding the [[ref: CESR]] representation of the public key out of the KEL and into its binary form (minus the leading '1AAA' or '1AAB' CESR codes) and generating the corresponding representation of the key in JSON Web Key form. For example, a KERI AID with only the following inception event in its KEL: + ```json { "v": "KERI10JSON0001ad_", @@ -553,6 +571,7 @@ For example, a KERI AID with only the following inception event in its KEL: // ... } ``` + would result in a DID document with the following verification methods array: ```json @@ -573,6 +592,7 @@ would result in a DID document with the following verification methods array: ``` #### Thresholds + 1. If the current signing keys threshold (the value of the `kt` field) is a string containing a number that is greater than 1, or if it is an array containing fractionally weighted thresholds, then in addition to the verification methods generated according to the rules in the previous sections, another verification method with a type of `ConditionalProof2022` MUST be generated in the DID document. This verification method type is defined [here](https://w3c-ccg.github.io/verifiable-conditions/). 1. It MUST be constructed according to the following rules: 1. The `id` property of the verification method MUST be a relative DID URL and use the AID as the value of the fragment component, e.g., `"id": "#"`. @@ -589,6 +609,7 @@ would result in a DID document with the following verification methods array: 1. The JSON object MUST contain a property `weight` whose value is the numerator of the fraction after it has been expanded over the lowest common denominator (LCD) of all the fractions. For example, a KERI AID with only the following inception event in its KEL, and with a `kt` value greater than 1: + ```json { "v": "KERI10JSON0001b7_", @@ -604,7 +625,9 @@ would result in a DID document with the following verification methods array: ], } ``` + results in a DID document with the following verification methods array: + ```json { "verificationMethod": [ @@ -658,6 +681,7 @@ would result in a DID document with the following verification methods array: ``` For example, a KERI AID with only the following inception event in its KEL, and a `kt` containing fractionally weighted thresholds: + ```json { "v": "KERI10JSON0001b7_", @@ -673,6 +697,7 @@ would result in a DID document with the following verification methods array: ], } ``` + would result in a DID document with the following verification methods array: ```json @@ -761,12 +786,14 @@ It is important to note that DID document service endpoints are different than t ::: #### KERI Service Endpoints as DID Document Metadata + 1. `did:webs` endpoints MUST be specified using the two data sets KERI uses to define service endpoints; Location Schemes and Endpoint Role Authorizations. 1. Both MUST be expressed in KERI `rpy` events. 1. For URL scheme endpoints that an AID has exposed, `did:webs` DIDs MUST use Location Schemes URLs. 1. For endpoints that relate a role of one AID to another, `did:webs` DIDs MUST use KERI Endpoint Role Authorizations. For example, the following `rpy` method declares that the AID `EIDJUg2eR8YGZssffpuqQyiXcRVz2_Gw_fcAVWpUMie1` exposes the URL `http://localhost:3902` for scheme `http`: + ```json { // ... @@ -779,7 +806,9 @@ It is important to note that DID document service endpoints are different than t } } ``` + For example, the AID listed in `cid` is the source of the authorization, the `role` is the role and the AID listed in the `eid` field is the target of the authorization. So in this example `EOGL1KGpOnRaZDIB11uZDCkhHs52_MtMXHd7EqUqwtA3` is being authorized as an Agent for `EIDJUg2eR8YGZssffpuqQyiXcRVz2_Gw_fcAVWpUMie1`. + ```json { // ... @@ -804,12 +833,14 @@ In KERI, service endpoints are defined by 2 sets of signed data using Best Avail This section is normative. The DID document that exists as a resource on a webserver is compatible with the `did:web` DID method and therefore necessarily different from a `did:webs` DID document with regard to the `id`, `controller`, and `alsoKnownAs` properties. + 1. To transform the `did:webs` form of the DID Document to a `did:web` the transformation MUST do the following: 1. In the values of the top-level `id` and `controller` properties of the DID document, the transformation MUST replace the `did:webs` prefix string with `did:web`. 1. In the value of the top-level `alsoKnownAs` property, the transformation MUST replace the entry that is now the new value of the `id` property (using `did:web`) with the old value of the `id` property (using `did:webs`). 1. All other content of the DID document MUST not be modified. For example, this transformation is used during the [Create](#create) DID method operation, given the following `did:webs` DID document: + ```json { "id": "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", @@ -835,7 +866,9 @@ The DID document that exists as a resource on a webserver is compatible with the ] } ``` + the result of the transformation algorithm is the following `did:web` DID document: + ```json { "id": "did:web:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", @@ -860,13 +893,14 @@ The DID document that exists as a resource on a webserver is compatible with the "did:webs:foo.com:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe" ] } - ``` + ``` ### Transformation to `did:webs` DID Document This section is normative. This section defines an inverse transformation algorithm from a `did:web` DID document to a `did:webs` DID document. + 1. Given a `did:web` DID document, a transformation to a `did:webs` DID document MUST have the following differences: 1. In the values of the top-level `id` and `controller` properties of the DID document, the transformation MUST replace the `did:web` prefix string with `did:webs`. 1. The value of the top-level `alsoKnownAs` property MUST replace the entry that is now the new value of the `id` property (using `did:webs`) with the old value of the `id` property (using `did:web`). @@ -874,6 +908,7 @@ This section defines an inverse transformation algorithm from a `did:web` DID do 1. A `did:webs` resolver MUST use this transformation during the [Read (Resolve)](#read-resolve) DID method operation. For example, given the following `did:web` DID document: + ```json { "id": "did:web:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", @@ -899,7 +934,9 @@ This section defines an inverse transformation algorithm from a `did:web` DID do ] } ``` + the result of the transformation algorithm is the following `did:webs` DID document: + ```json { "id": "did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe", @@ -927,11 +964,13 @@ This section defines an inverse transformation algorithm from a `did:web` DID do ``` ### Full Example + ::: informative Full Example To walk through a real-world example, please see the GETTING STARTED guide in the [[ref: didwebs Reference Implementation]] as it walks users through many did:webs related tasks (and associated KERI commands) to demonstrate how they work together. The following blocks contain fully annotated examples of a KERI AID with two events, an [[ref: inception event]] and an [[ref: interaction event]]. + * The Inception event designates some [[ref: witnesses]] in the `b` field. * The Inception event designates multiple public signing keys in the `k` field. * The Inception event designates multiple rotation keys in the `n` field. @@ -1055,6 +1094,7 @@ Below, we show the KERI Event Stream that will be associated with the resulting ``` Resulting DID document: + ```json "didDocument": { "id": "did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M", @@ -1136,6 +1176,7 @@ This section is normative. [DID Documents](#did-documents) introduced the core [[ref: KERI event stream]] and related DID Document concepts. This section provides additional details regarding the basic types of KERI events and how they relate to the DID document. #### Key state events + 1. When processing the KERI event stream `did:webs` MUST account for two broad types of key state events (KERI parlance is 'establishment events') that can alter the key state of the AID. 1. Any change in key state of the AID MUST be reflected in the DID document. 1. If a key state event does not commit to a future set of rotation key hashes, then the AID SHALL NOT be rotated to new keys in the future (KERI parlance is that the key state of the AID becomes 'non-transferrable'). @@ -1143,7 +1184,7 @@ This section is normative. 1. The [[ref: Inception event]] MUST be the first event in the [[ref: KEL]] that establishes the AID. 1. This MUST define the initial key set 1. If the controller(s) desire future key rotation (transfer) then the inception event MUST commit to a set of future rotation key hashes. - 1. When processing the [[ref: KERI event stream]], if there are no rotation events after the inception event, then this is the current key state of the AID and MUST be reflected in the DID Document as specified in [Verification Methods](#verification-methods) and [Verification Relationships](#verification-relationships). + 1. When processing the [[ref: KERI event stream]], if there are no rotation events after the inception event, then this is the current key state of the AID and MUST be reflected in the DID Document as specified in [Verification Methods](#verification-methods) and [Verification Relationships](#verification-relationships). 1. [[ref: Rotation events]] MUST come after inception events. 1. If the controller(s) desires future key rotation (transfer) then the rotation event MUST commit to a set of future rotation key hashes. 1. Rotation events MUST only change the key state to the previously committed to rotation keys. @@ -1158,9 +1199,11 @@ To learn about future rotation key commitment, see the sections about [pre-rotat ::: ### Delegation KERI event details + This section focuses on delegation relationships between KERI AIDs. [DID Documents](#did-documents) introduced the core [[ref: KERI event stream]] and related DID Document concepts. This section provides additional details regarding the types of KERI delegation events and how they relate to the DID document. See [Basic KERI event details](#basic-keri-event-details) for further detail on basic KERI event types including how they relate to the DID document. #### Delegation key state events + 1. All delegation relationships MUST start with a delegated inception event. 1. Any change to the [[ref: Delegated inception event]] key state or delegated rotation event key state MUST be the result of a delegated rotation event. @@ -1178,13 +1221,13 @@ Delegation service endpoints in the DID document are defined in the next section This section is normative. -In did:webs, KERI-derived service endpoints are defined by **Location Scheme** (`/loc/scheme`) reply (`rpy`) messages and, for roles other than witness, **Endpoint Role Authorization** (`/end/role/add`) `rpy` messages in the [[ref: KERI event stream]]. Location Scheme records declare URL(s) for a given scheme for an AID; Endpoint Role Authorization relates a role (e.g. mailbox, agent) of one AID to another. See [KERI Service Endpoints as DID Document Metadata](#keri-service-endpoints-as-did-document-metadata). +In did:webs, KERI-derived service endpoints are defined by **Location Scheme** (`/loc/scheme`) reply (`rpy`) messages and, for roles other than witness, **Endpoint Role Authorization** (`/end/role/add`) `rpy` messages in the [[ref: KERI event stream]]. Location Scheme records declare URL(s) for a given scheme for an AID; Endpoint Role Authorization relates a role (e.g. mailbox, agent) of one AID to another. See [KERI Service Endpoints as DID Document Metadata](#keri-service-endpoints-as-did-document-metadata). When the event stream (or equivalent key state and endpoint data) for a `did:webs` DID establishes a witness, mailbox, or agent the DID document MUST include the associated service endpoint(s) in its `service` array. #### Witness Service Endpoint -1. A witness service endpoint is produced when (1) the controller AID's [[ref: KEL]] designates the witness in its witness list (inception or latest rotation event `b` field), and (2) one or more Location Scheme `rpy` messages with `r` `/loc/scheme` declare URLs for that witness AID (`a.eid`) per scheme. The witness role is thus established by key state, not by an Endpoint Role Authorization `rpy`. +1. A witness service endpoint is produced when (1) the controller AID's [[ref: KEL]] designates the witness in its witness list (inception or latest rotation event `b` field), and (2) one or more Location Scheme `rpy` messages with `r` `/loc/scheme` declare URLs for that witness AID (`a.eid`) per scheme. The witness role is thus established by key state, not by an Endpoint Role Authorization `rpy`. 2. The DID document service entry SHALL use `type` `witness`, `id` relative to the DID of the form `#/witness`, and `serviceEndpoint` as an object whose keys are scheme names and values are the declared URLs. Location Scheme examples (witness AID declares https and tcp URLs): @@ -1230,7 +1273,7 @@ Resulting witness service entry: #### Mailbox Service Endpoint -1. A mailbox service endpoint is produced when (1) an Endpoint Role Authorization `rpy` with `r` `/end/role/add` and `a.role` `mailbox` designates the mailbox AID (`a.eid`) for the controller AID (`a.cid`), and (2) one or more Location Scheme `rpy` messages with `r` `/loc/scheme` declare URLs for that mailbox AID. Implementations obtain mailbox endpoints from Endpoint Role data (e.g. KERI `ends` table keyed by controller and role) plus Location Scheme data (e.g. `locs` table). +1. A mailbox service endpoint is produced when (1) an Endpoint Role Authorization `rpy` with `r` `/end/role/add` and `a.role` `mailbox` designates the mailbox AID (`a.eid`) for the controller AID (`a.cid`), and (2) one or more Location Scheme `rpy` messages with `r` `/loc/scheme` declare URLs for that mailbox AID. Implementations obtain mailbox endpoints from Endpoint Role data (e.g. KERI `ends` table keyed by controller and role) plus Location Scheme data (e.g. `locs` table). 2. The DID document service entry SHALL use `type` `mailbox`, `id` relative to the DID of the form `#/mailbox`, and `serviceEndpoint` as an object mapping scheme names to URLs (or a single URL when only one scheme applies). Endpoint Role Authorization example (controller designates mailbox): @@ -1277,7 +1320,7 @@ Resulting mailbox service entry: #### Agent Service Endpoint -1. An agent service endpoint is produced when (1) an Endpoint Role Authorization `rpy` with `r` `/end/role/add` and `a.role` `agent` designates the agent AID (`a.eid`) for the controller AID (`a.cid`), and (2) one or more Location Scheme `rpy` messages with `r` `/loc/scheme` declare URLs for that agent AID. Implementations obtain agent endpoints from Endpoint Role data (e.g. KERI `ends` table) plus Location Scheme data (e.g. `locs` table). +1. An agent service endpoint is produced when (1) an Endpoint Role Authorization `rpy` with `r` `/end/role/add` and `a.role` `agent` designates the agent AID (`a.eid`) for the controller AID (`a.cid`), and (2) one or more Location Scheme `rpy` messages with `r` `/loc/scheme` declare URLs for that agent AID. Implementations obtain agent endpoints from Endpoint Role data (e.g. KERI `ends` table) plus Location Scheme data (e.g. `locs` table). 2. The DID document service entry SHALL use `type` `KeriAgent` (or `agent` where registered) and `serviceEndpoint` as an object mapping scheme names to URLs or a single URL, consistent with [KERI Service Endpoints as DID Document Metadata](#keri-service-endpoints-as-did-document-metadata). Endpoint Role Authorization example (controller designates agent): @@ -1350,6 +1393,7 @@ In this example, the `id` field contains the [[ref: SAID]] of the seal in the de ::: ### Designated Aliases + 1. An AID controller SHALL specify the [[ref: designated aliases]] that will be listed in the `equivalentId` and `alsoKnownAs` properties by issuing a Designated aliases verifiable attestation as an ACDC. 1. This attestation MUST contain a set of [[ref: AID controlled identifiers]] that the AID controller authorizes. 1. If the identifier is a `did:webs` identifier then it is truly equivalent and MUST be listed in the `equivalentId` property. @@ -1359,6 +1403,7 @@ In this example, the `id` field contains the [[ref: SAID]] of the seal in the de ::: informative Designated aliases example This is an example [[ref: designated aliases]] [[ref: ACDC]] attestation showing five designated aliases: + ```json { "v": "ACDC10JSON0005f2_", @@ -1382,12 +1427,15 @@ This is an example [[ref: designated aliases]] [[ref: ACDC]] attestation showing } } ``` + The resulting DID document based on the [[ref: designated aliases]] attestation above, contains: + * An `equivalentId` metadata for the did:webs:foo.com identifier * Three `alsoKnownAs` identifiers: * the did:webs:foo.com identifier is a Designated alias which is also in the equivalentId did document metadata. * the did:web:example.com is a Designated alias * NOTE: if the did:keri identifier were automatically generated and included from the AID then that would be a valid designated alias and alsoKnownAs value based on the AID + ```json { "didDocument": { @@ -1433,11 +1481,12 @@ The resulting DID document based on the [[ref: designated aliases]] attestation ::: - --- ## did_metadata + ## DID Metadata + This section is normative. This section describes the support of the `did:webs` method for metadata, including [[ref: DID resolution metadata]] and [[ref: DID document metadata]]. This metadata is returned by a DID Resolver in addition to the DID document. Also see the [DID Resolution](https://w3c.github.io/did-resolution/) specification for further details. @@ -1505,7 +1554,7 @@ The `equivalentId` DID document metadata property indicates other DIDs that refe 1. The `did:webs` `equivalentId` metadata property SHOULD contain a list of the controller AID [[ref: designated aliases]] `did:webs` DIDs that differ in the [[ref: host]] and/or port portion of the [[ref: method-specific identifier]] but share the same AID. Also see section [[ref: AID controlled identifiers]]. 1. `equivalentId` depends on the controller AIDs array of [[ref: designated aliases]]. A `did:webs` identifier MUST not verify unless it is found in the `equivalentId` metadata that corresponds to the Designated aliases. -> Note that [[ref: AID controlled identifiers]] like `did:web` and `did:keri` identifiers with the same AID are not listed in `equivalentId` because they do not have the same DID method. A `did:web` identifier with the same domain and AID does not have the same security characteristics as the `did:webs` identifier. Conversely, a `did:keri` identifier with the same AID has the same security characterisitcs but not the same dependence on the web. For these reasons, they are not listed in `equivalentId`. +> Note that [[ref: AID controlled identifiers]] like `did:web` and `did:keri` identifiers with the same AID are not listed in `equivalentId` because they do not have the same DID method. A `did:web` identifier with the same domain and AID does not have the same security characteristics as the `did:webs` identifier. Conversely, a `did:keri` identifier with the same AID has the same security characterisitcs but not the same dependence on the web. For these reasons, they are not listed in `equivalentId`. Example: @@ -1555,7 +1604,9 @@ Example: --- ## didparameters + ## DID Parameters + This section is normative. This section describes the support of the `did:webs` method for certain DID parameters. @@ -1606,6 +1657,7 @@ This specification defines the following extensions to the DID document data mod ::: informative CesrKey example For example, a KERI AID with only the following inception event in its KEL: + ```json { "v": "KERI10JSON0001b7_", @@ -1622,11 +1674,15 @@ For example, a KERI AID with only the following inception event in its KEL: // ... } ``` + and given the following the DID URL: + ``` did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M?transformKeys=CesrKey ``` + would result in a DID document with the following verification methods array: + ```json { "verificationMethod": [ @@ -1654,14 +1710,15 @@ would result in a DID document with the following verification methods array: ::: - --- ## security_considerations + ## Security Considerations + This section is normative. -There are many security considerations related to web requests, storing information securely, etc. It is useful to address these considerations along with the common security threats found on the web. +There are many security considerations related to web requests, storing information securely, etc. It is useful to address these considerations along with the common security threats found on the web. ### Common security threats @@ -1678,8 +1735,10 @@ There are many security considerations related to web requests, storing informat 1. Replay attacks MUST be eliminated or reduced. ### Using HTTPS + Perfect protection from eavesdropping is not possible with HTTPS, for various reasons. + 1. URLs of DID documents and [[ref: KERI event streams]] SHOULD be hosted in a way that embodies accepted cybersecurity best practice. This is not strictly necessary to guarantee the authenticity of the data. However, the usage: 1. MUST safeguard privacy 1. MUST discourage denial of service @@ -1713,6 +1772,7 @@ reasons. ### Concepts for securing `did:webs` information The following security concepts are used to secure the data, files, signatures and other information in `did:webs`. + 1. All security features and concepts in `did:webs` MUST use one or more of the following mechanisms: 1. All data that requires the highest security MUST be [[ref: KEL]] backed. This includes any information that needs to be end-verifiably authentic over time: 1. All [[ref: ACDCs]] used by a `did:webs` identifier MUST be one of the following: @@ -1724,26 +1784,29 @@ The following security concepts are used to secure the data, files, signatures a 1. key state MUST be used. 1. Discovery information MAY use BADA-RUN because the worst-case attack on discovery information is a DDoS attack where nothing gets discovered. 1. The controller(s) of the AID for a `did:webs` identifier MAY use BADA-RUN for service end-points as discovery mechanisms. - 2. All data that does not need the security of being KEL backed nor BADA-RUN SHOULD be served using _KERI Request Authentication Mechanism_ ([[ref: KRAM]]). + 1. All data that does not need the security of being KEL backed nor BADA-RUN SHOULD be served using _KERI Request Authentication Mechanism_ ([[ref: KRAM]]). 1. For a `did:webs` resolver to be trusted it SHOULD use KRAM to access the service endpoints providing KERI event streams for verification of the DID document. #### Reducing the attack surface + ::: informative Reducing the attack surface The above considerations have lead us to focus on KEL backed DID document blocks and data (designated alias ACDCs, signatures, etc) so that the trusted (local) did:webs resolver is secure. Any future features that could leverage BADA-RUN and [[ref: KRAM]] should be considered carefully according to the above considerations. See the implementors guide for more details about KEL backed, BADA-RUN, and KRAM: + * [[ref: On-Disk Storage]] * [Alignment of Information to Security Posture](#alignment-of-information-to-security-posture) * [Applying the concepts of KEL, BADA-RUN, and KRAM](#applying-the-concepts-of-kel) ::: - --- ## privacy_considerations + ## Privacy Considerations + ::: informative Privacy Considerations This section addresses the privacy considerations from [RFC6973](https://datatracker.ietf.org/doc/html/rfc6973) section 5. @@ -1806,4 +1869,3 @@ about them. ::: --- - diff --git a/docs/specs/references/gx-architecture-document-25.11.md b/docs/specs/references/gx-architecture-document-25.11.md new file mode 100644 index 0000000..eb46a11 --- /dev/null +++ b/docs/specs/references/gx-architecture-document-25.11.md @@ -0,0 +1,108 @@ +# Gaia-X Architecture Document 25.11 + +**Status:** Published (November 2025) +**Publisher:** Gaia-X European Association for Data and Cloud AISBL +**URL:** https://docs.gaia-x.eu/technical-committee/architecture-document/25.11/ +**PDF:** https://docs.gaia-x.eu/technical-committee/architecture-document/25.11/pdf/document.pdf +**License:** CC BY-NC-ND 4.0 + +## Local Artifacts + +The Gaia-X ontology, SHACL shapes, and JSON-LD context are maintained +locally in the ontology-management-base (OMB) submodule: + +| File | Path (relative to OMB root) | +|------|-----------------------------| +| OWL ontology | `artifacts/gx/gx.owl.ttl` | +| SHACL shapes | `artifacts/gx/gx.shacl.ttl` | +| JSON-LD context | `artifacts/gx/gx.context.jsonld` | +| Version | `artifacts/gx/VERSION` → `25.11+fix.1` | +| Properties summary | `artifacts/gx/PROPERTIES.md` | + +**Source submodule:** `submodules/service-characteristics` (upstream GitLab) +**Namespace:** `https://w3id.org/gaia-x/development#` (prefix `gx:`) + +## Overview + +The Gaia-X Architecture Document defines the technical framework for +the Gaia-X ecosystem, including identity, trust, and compliance +requirements for participants and service offerings. It specifies +SHACL shapes and ontology terms under the `gx:` namespace. + +## Key Concepts for Harbour + +### Participant Types + +| Type | Namespace | Description | +|------|-----------|-------------| +| `gx:Participant` | `https://w3id.org/gaia-x/development#` | Base participant type | +| `gx:LegalPerson` | `https://w3id.org/gaia-x/development#` | Organization participant (extends gx:Participant) | + +### gx:LegalPersonShape (from `gx.shacl.ttl`) + +The shape is **closed** (`sh:closed true`), meaning only declared properties +are permitted on `gx:LegalPerson` nodes: + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `gx:registrationNumber` | `gx:RegistrationNumber` | MUST (≥1) | Country's registration number (EUID, EORI, vatID, leiCode) | +| `gx:legalAddress` | `gx:Address` | MUST (=1) | Full legal/registered address | +| `gx:headquartersAddress` | `gx:Address` | MUST (=1) | Full physical HQ address | +| `gx:parentOrganizationOf` | `gx:LegalPerson` | OPTIONAL | Parent org links | +| `gx:subOrganisationOf` | `gx:LegalPerson` | OPTIONAL | Subsidiary links (mandated entities) | +| `schema:name` | `xsd:string` | OPTIONAL (≤1) | Human-readable name | +| `schema:description` | `xsd:string` | OPTIONAL (≤1) | Description | + +### Closed Shape Constraint — Why Composition + +Because `gx:LegalPersonShape` has `sh:closed true`: + +- Adding ANY property not in the shape to a `gx:LegalPerson` node + will **fail** SHACL validation. +- Harbour cannot extend `gx:LegalPerson` with additional properties. +- Therefore Harbour uses **composition** (not extension): the harbour outer + node carries harbour-specific properties, and a nested gx blank node + carries only gx-valid properties. + +### Composition Pattern + +``` +harbour:LegalPerson # harbour outer node + ├── schema:name "ACME Corp" # harbour property + └── harbour:gxParticipant # composition link + └── gx:LegalPerson # gx blank node (closed shape) + ├── gx:registrationNumber ... + ├── gx:headquartersAddress ... + └── gx:legalAddress ... +``` + +This pattern keeps gx closed shapes intact while allowing harbour to +carry its own properties on the outer node. + +### Trust Framework Compliance + +- Participants MUST present Gaia-X Compliance Credentials. +- Compliance credentials are issued by Gaia-X-accredited notaries. +- The Gaia-X Compliance Service validates participant data against + SHACL shapes and issues compliance credentials. + +## Related Documents + +| Document | URL | +|----------|-----| +| Gaia-X Ontology (IRI) | https://w3id.org/gaia-x/development | +| Gaia-X Shapes (catalog IRI) | https://w3id.org/gaia-x/development#shapes | +| Gaia-X Trust Framework | https://docs.gaia-x.eu/ | +| Gaia-X Compliance Service | https://compliance.gaia-x.eu/ | +| Gaia-X Registry | https://registry.gaia-x.eu/ | +| Upstream submodule (GitLab) | https://gitlab.com/gaia-x/technical-committee/service-characteristics-working-group/service-characteristics | + +## Harbour Usage + +- `harbour-gx-credential.yaml` defines `LegalPersonCredential` and + `NaturalPersonCredential` with `gxParticipant` composition slot. +- The `gxParticipant` slot has `range: Any` because the gx blank node + content is validated by gx's own SHACL shapes, not harbour's. +- Domain SHACL is generated with `exclude_imports=True` to keep + harbour shapes separate from gx shapes. +- Version tracking via `artifacts/gx/VERSION` and `verify-version.sh`. diff --git a/docs/specs/references/keri-draft.md b/docs/specs/references/keri-draft.md index f62830b..6c8bf9d 100644 --- a/docs/specs/references/keri-draft.md +++ b/docs/specs/references/keri-draft.md @@ -14,8 +14,9 @@ stand_alone: yes smart_quotes: no pi: [toc, sortrefs, symrefs] -author: +author - + name: S. Smith organization: ProSapien LLC email: sam@prosapien.com @@ -83,7 +84,6 @@ normative: target: https://github.com/msgpack/msgpack/blob/master/spec.md title: Msgpack Mapping Object Codes - informative: KERI: @@ -456,16 +456,12 @@ informative: target: https://en.wikipedia.org/wiki/Ricardian_contract title: Ricardian Contract - --- abstract An identity system-based secure overlay for the Internet is presented. This is based on a Key Event Receipt Infrastructure (KERI) or the KERI protocol {{KERI}}{{KERI-ID}}{{RFC0791}}. This includes a primary root-of-trust in self-certifying identifiers (SCIDs) {{UIT}}{{SCPK}}{{SFS}}{{SCPN}}{{SCURL}}. It presents a formalism for Autonomic Identifiers (AIDs) and Autonomic Namespaces (ANs). They are part of an Autonomic Identity System (AIS). This system uses the design principle of minimally sufficient means to provide a candidate trust spanning layer for the internet. Associated with this system is a decentralized key management infrastructure (DKMI). The primary root-of-trust are self-certifying identifiers that are strongly bound at issuance to a cryptographic signing (public, private) keypair. These are self-contained until/unless control needs to be transferred to a new keypair. In that event, an append-only chained key-event log of signed transfer statements provides end verifiable control provenance. This makes intervening operational infrastructure replaceable because the event logs may be served up by any infrastructure including ambient infrastructure. End verifiable logs on ambient infrastructure enable ambient verifiability (verifiable by anyone, anywhere, at any time). The primary key management operation is key rotation (transference) via a novel key pre-rotation scheme {{DAD}}{{KERI}}. Two primary trust modalities motivated the design, these are a direct (one-to-one) mode and an indirect (one-to-any) mode. The indirect mode depends on witnessed key event receipt logs (KERL) as a secondary root-of-trust for validating events. This gives rise to the acronym KERI for key event receipt infrastructure. In the direct mode, the identity controller establishes control via verified signatures of the controlling keypair. The indirect mode extends that trust basis with witnessed key event receipt logs (KERL) for validating events. The security and accountability guarantees of indirect mode are provided by KA2CE or KERI’s Agreement Algorithm for Control Establishment among a set of witnesses. The KA2CE approach may be much more performant and scalable than more complex approaches that depend on a total ordering distributed consensus ledger. Nevertheless, KERI may employ a distributed consensus ledger when other considerations make it the best choice. The KERI approach to DKMI allows for more granular composition. Moreover, because KERI is event streamed it enables DKMI that operates in-stride with data events streaming applications such as web 3.0, IoT, and others where performance and scalability are more important. The core KERI engine is identifier namespace independent. This makes KERI a candidate for a universal portable DKMI {{KERI}}{{KERI-ID}}{{UIT}}. - - - --- middle # Introduction @@ -487,12 +483,11 @@ This family of generalized enhanced SCIDs we call ***autonomic identifiers*** (A An important innovation of KERI is that it solves the key rotation problem of PKI (including that of simple self-certifying identifiers) via a novel but elegant mechanism we call ***key pre-rotation*** {{DAD}}{{KERI}}. This *pre-rotation* mechanism enables an entity to persistently maintain or regain control over an identifier in spite of the exposure-related weakening over time or even compromise of the current set of controlling (signing) keypairs. With key pre-rotation, control over the identifier can be re-established by rotating to a one-time use set of unexposed but pre-committed rotation keypairs that then become the current signing keypairs. Each rotation in turn cryptographically commits to a new set of rotation keys but without exposing them. Because the pre-rotated keypairs need never be exposed prior to their one-time use, their attack surface may be optimally minimized. The current key-state is maintained via an append-only ***verifiable data structure*** we call a ***key event log*** (KEL). - ## Cryptographic Primitives ### CESR -A ***cryptographic primitive ***is a serialization of a value associated with a cryptographic operation including but not limited to a digest (hash), a salt, a seed, a private key, a public key, or a signature. All cryptographic primitives in KERI MUST be expressed using the CESR (Compact Event Streaming Representation) protocol {{CESR-ID}}. CESR supports round trip lossless conversion between its text, binary, and raw domain representations and lossless composability between its text and binary domain representations. Composability is ensured between any concatenated group of text primitives and the binary equivalent of that group because all CESR primitives are aligned on 24-bit boundaries. Both the text and binary domain representations are serializations suitable for transmission over the wire. The text domain representation is also suitable to be embedded as a string value of a field or array element as part of a field map serialization such as JSON, CBOR, or MsgPack {{RFC8259}}{{JSOND}}{{RFC8949}}{{CBORC}}{{MGPK}}. The text domain uses the set of characters from the URL-safe variant of Base64 which in turn is a subset of the ASCII character set {{RFC4648}}{{RFC0020}}. For the sake of readability, all examples in this specification will be expressed in CESR's text-domain. +A ***cryptographic primitive***is a serialization of a value associated with a cryptographic operation including but not limited to a digest (hash), a salt, a seed, a private key, a public key, or a signature. All cryptographic primitives in KERI MUST be expressed using the CESR (Compact Event Streaming Representation) protocol {{CESR-ID}}. CESR supports round trip lossless conversion between its text, binary, and raw domain representations and lossless composability between its text and binary domain representations. Composability is ensured between any concatenated group of text primitives and the binary equivalent of that group because all CESR primitives are aligned on 24-bit boundaries. Both the text and binary domain representations are serializations suitable for transmission over the wire. The text domain representation is also suitable to be embedded as a string value of a field or array element as part of a field map serialization such as JSON, CBOR, or MsgPack {{RFC8259}}{{JSOND}}{{RFC8949}}{{CBORC}}{{MGPK}}. The text domain uses the set of characters from the URL-safe variant of Base64 which in turn is a subset of the ASCII character set {{RFC4648}}{{RFC0020}}. For the sake of readability, all examples in this specification will be expressed in CESR's text-domain. ### Qualified Cryptographic Primitive @@ -517,7 +512,6 @@ We call the state of the controlling keys for an identifier at any time the key The KERI protocol fixes both of these flaws using a combination of ***autonomic identifiers***, ***key pre-rotation***, a ***verifiable data structure*** (VDS) called a KEL as verifiable proof of key-state, and ***duplicity-evident*** mechanisms for evaluating and reconciling key state by validators {{KERI}}. Unlike certificate transparency, KERI enables the detection of duplicity in the key state via non-repudiable cryptographic proofs of duplicity not merely the detection of inconsistency in the key state that may or may not be duplicitous {{KERI}}{{CTAOL}}. - ### Triad Bindings In simple form an identifier-system security-overlay binds together a triad consisting of the ***identifier***, ***keypairs***, and ***controllers***. By ***identifier*** we mean some string of characters. By ***keypairs*** we mean a set of asymmetric (public, private) cryptographic keypairs used to create and verify non-repudiable digital signatures. By ***controllers*** we mean the set of entities whose members each control a private key from the given set of ***keypairs***. When those bindings are strong then the overlay is highly *invulnerable* to attack. In contrast, when those bindings are weak then the overlay is highly *vulnerable* to attack. The bindings for a given identifier form a *triad* that binds together the set of *controllers*, the set of *keypairs*, and the *identifier*. To reiterate, the set of controllers is bound to the set of keypairs, the set of keypairs is bound to the identifier, and the identifier is bound to the set of controllers. This binding triad can be diagrammed as a triangle where the sides are the bindings and the vertices are the *identifier*, the set of *controllers*, and the set of *keypairs*. This triad provides verifiable ***control authority*** for the identifier. @@ -610,7 +604,8 @@ Verifiable Duplicity : Means the existence of more than one version of a verifiable KEL for a given AID. Because every event in a KEL must be signed with non-repudiable signatures any inconsistency between any two instances of the KEL for a given AID is provable evidence of duplicity on the part of the signers with respect to either or both the key-state of that AID and/or any anchored data at a given key-state. A shorter KEL that does not differ in any of its events with respect to another but longer KEL is not duplicitous but merely incomplete. To clarify, duplicity evident means that duplicity is provable via the presentation of a set of two or more mutually inconsistent but independently verifiable instances of a KEL. -Verifier : Any entity or agent that cryptographically verifies the signature(s) and/or digests on an event message. In order to verify a signature, a verifier must first determine which set of keys are or were the controlling set for an identifier when an event was issued. In other words, a verifier must first establish control authority for an identifier. For identifiers that are declared as non-transferable at inception, this control establishment merely requires a copy of the inception event for the identifier. For identifiers that are declared transferable at inception, this control establishment requires a complete copy of the sequence of establishment events (inception and all rotations) for the identifier up to the time at which the statement was issued. +Verifier +: Any entity or agent that cryptographically verifies the signature(s) and/or digests on an event message. In order to verify a signature, a verifier must first determine which set of keys are or were the controlling set for an identifier when an event was issued. In other words, a verifier must first establish control authority for an identifier. For identifiers that are declared as non-transferable at inception, this control establishment merely requires a copy of the inception event for the identifier. For identifiers that are declared transferable at inception, this control establishment requires a complete copy of the sequence of establishment events (inception and all rotations) for the identifier up to the time at which the statement was issued. Validator : Any entity or agent that evaluates whether or not a given signed statement as attributed to an identifier is valid at the time of its issuance. A valid statement MUST be verifiable, that is, has a verifiable signature from the current controlling keypair(s) at the time of its issuance. Therefore a *Validator* must first act as a *Verifier* in order to establish the root authoritative set of keys. Once verified, the *Validator* may apply other criteria or constraints to the statement in order to determine its validity for a given use case. When that statement is part of a verifiable data structure then the cryptographic verification includes verifying digests and any other structural commitments or constraints. To elaborate, with respect to an AID, for example, a *Validator* first evaluates one or more KELs in order to determine if it can rely on (trust) the key state (control authority) provided by any given KEL. A necessary but insufficient condition for a valid KEL is it is verifiable i.e. is internally inconsistent with respect to compliance with the KERI protocol. An invalid KEL from the perspective of a Validator may be either unverifiable or may be verifiable but duplicitous with respect to some other verifiable version of that KEL. Detected duplicity by a given validator means that the validator has seen more than one verifiable version of a KEL for a given AID. Reconciliable duplicity means that one and only one version of a KEL as seen by a Validator is accepted as the authoritative version for that validator. Irreconcilable duplicity means that none of the versions of a KEL as seen by a validator are accepted as the authoritative one for that validator. The conditions for reconcilable duplicity are described later. @@ -624,14 +619,13 @@ Key Event Message Key Event Receipt : Message whose body references a key event and whose attachments MUST include one or more signatures on that key event. - # Keypair Labeling Convention In order to make key event expressions both clearer and more concise, we use a keypair labeling convention. When an AID's key state is dynamic, i.e. the set of controlling keypairs is transferable, then the keypair labels are indexed in order to represent the successive sets of keypairs that constitute the key state at any position in the KEL (key event log). To elaborate, we use indexes on the labels for AIDs that are transferable to indicate which set of keypairs is associated with the AID at any given point in its key state or KEL. In contrast, when the key state is static, i.e. the set of controlling keypairs is non-transferable then no indexes are needed because the key state never changes. Recall that, a keypair is a two tuple, *(public, private)*, of the respective public and private keys in the keypair. For a given AID, the labeling convention uses an uppercase letter label to represent that AID. When the key state is dynamic, a superscripted index on that letter is used to indicate which keypair is used at a given key state. Alternatively, the index may be omitted when the context defines which keypair and which key state, such as, for example, the latest or current key state. To reiterate, when the key state is static no index is needed. -In general, without loss of specificity, we use an uppercase letter label to represent both an AID and when indexed to represent its keypair or keypairs that are authoritative at a given key state for that AID. In addition, when expressed in tuple form the uppercase letter also represents the public key and the lowercase letter represents the private key for a given keypair. For example, let *A* denote and AID, then let* A* also denote a keypair which may be also expressed in tuple form as *(A, a)*. Therefore, when referring to the keypair itself as a pair and not the individual members of the pair, either the uppercase label, *A*, or the tuple, *(A, a)*, may be used to refer to the keypair itself. When referring to the individual members of the keypair then the uppercase letter, *A*, refers to the public key, and the lowercase letter, *a*, refers to the private key. +In general, without loss of specificity, we use an uppercase letter label to represent both an AID and when indexed to represent its keypair or keypairs that are authoritative at a given key state for that AID. In addition, when expressed in tuple form the uppercase letter also represents the public key and the lowercase letter represents the private key for a given keypair. For example, let *A* denote and AID, then let*A* also denote a keypair which may be also expressed in tuple form as *(A, a)*. Therefore, when referring to the keypair itself as a pair and not the individual members of the pair, either the uppercase label, *A*, or the tuple, *(A, a)*, may be used to refer to the keypair itself. When referring to the individual members of the keypair then the uppercase letter, *A*, refers to the public key, and the lowercase letter, *a*, refers to the private key. Let the sequence of keypairs that are authoritative (i.e establish control authority) for an AID be indexed by the zero-based integer-valued, strictly increasing by one, variable *i*. Furthermore, as described above, an establishment key event may change the key state. Let the sequence of establishment events be indexed by the zero-based integer-valued, strictly increasing by one, variable *j*. When the set of controlling keypairs that are authoritative for a given key state includes only one member, then *i = j* for every keypair, and only one index is needed. But when the set of keypairs used at any time for a given key state includes more than one member, then *i != j* for every keypair, and both indices are needed. @@ -687,9 +681,9 @@ The following example illustrates the lifecycle roles of the key sets drawn from |1| *[A3,1, A4,1]* | 1 | *[H(A5,2), H(A6,2), H(A7,2)]* | 2 | |2| *[A5,2, A6,2, A7,2]* | 2 | *[H(A8,3), H(A9,3), H(A10,3]* | 2 | -+ *CTH* means Current Threshold. +* *CTH* means Current Threshold. -+ *NTH* means Next Threshold. +* *NTH* means Next Threshold. ## Reserve Rotation @@ -748,25 +742,24 @@ Provided here is an illustrative example to help to clarify the pre-rotation pro The meaning of the column labels is as follows: -+ SN is the sequence number of the event. Each event uses two rows in the table. -+ Role is either Current (Crnt) or Next and indicates the role of the key list and threshold on that row. -+ Keys is the list of public keys denoted with indexed label of the keypair sequence. -+ Threshold is the threshold of signatures that must be satisfied for validity. +* SN is the sequence number of the event. Each event uses two rows in the table. +* Role is either Current (Crnt) or Next and indicates the role of the key list and threshold on that row. +* Keys is the list of public keys denoted with indexed label of the keypair sequence. +* Threshold is the threshold of signatures that must be satisfied for validity. Commentary of each event follows: -+ (0) Inception: Five keypairs have signing authority and five other keypairs have rotation authority. Any two of the first three or any one of the first three and both the last two are sufficient. This anticipates holding the last two in reserve. +* (0) Inception: Five keypairs have signing authority and five other keypairs have rotation authority. Any two of the first three or any one of the first three and both the last two are sufficient. This anticipates holding the last two in reserve. -+ (1) Rotation: The first three keypairs from the prior next, A5, A6, and A7, are rotated at the new current signing keypairs. This exposes the keypairs. The last two from the prior next, A8 and A9, are held in reserve. They have not been exposed are included in the next key list. +* (1) Rotation: The first three keypairs from the prior next, A5, A6, and A7, are rotated at the new current signing keypairs. This exposes the keypairs. The last two from the prior next, A8 and A9, are held in reserve. They have not been exposed are included in the next key list. -+ (2) Rotation: The prior next keypairs, A11 and A12 are unavalible to sign the rotation and particpate as the part of the newly current signing keys. Therefore A8 and A9 must be activated (pulled out of reserve) and included and exposed as both one time rotation keys and newly current signing keys. The signing authority (weight) of each of A8 and A9 has been increased to 1/2 from 1/4. This means that any two of the three of A10, A8, and A9 may satisfy the signing threshold. Nonetheless, the rotation event \#2 MUST be signed by all three of A10, A8, and A9 in order to satisfy the prior next threshold because in that threshold A8, and A9 only have a weight of 1/4. +* (2) Rotation: The prior next keypairs, A11 and A12 are unavalible to sign the rotation and particpate as the part of the newly current signing keys. Therefore A8 and A9 must be activated (pulled out of reserve) and included and exposed as both one time rotation keys and newly current signing keys. The signing authority (weight) of each of A8 and A9 has been increased to 1/2 from 1/4. This means that any two of the three of A10, A8, and A9 may satisfy the signing threshold. Nonetheless, the rotation event \#2 MUST be signed by all three of A10, A8, and A9 in order to satisfy the prior next threshold because in that threshold A8, and A9 only have a weight of 1/4. -+ (3) Rotation: The keypairs H(A16),H(A17 have been held in reserve from event \#2 +* (3) Rotation: The keypairs H(A16),H(A17 have been held in reserve from event \#2 -+ (4) Rotation: The keypairs H(A16), H(A17 continue to be held in reserve. - -+ (5) Rotation: The keypairs A16, and A17 are pulled out of reserved and exposed in order to perform the rotation because A23, and A24 are unavailable. Two new keypairs, A25, A26, are added to the current signing key list. The current signing authority of A16, and A17 is none because they are assigned a weight of 0 in the new current signing threshold. For the rotation event to be valid it must be signed by A22, A16, and A17 in order to satisfy the prior next threshold for rotation authority and also must be signed by any two of A22, A25, and A26 in order to satisfy the new current signing authority for the event itself. This illustrates how reserved keypairs may be used exclusively for rotation authority and not for signing authority. +* (4) Rotation: The keypairs H(A16), H(A17 continue to be held in reserve. +* (5) Rotation: The keypairs A16, and A17 are pulled out of reserved and exposed in order to perform the rotation because A23, and A24 are unavailable. Two new keypairs, A25, A26, are added to the current signing key list. The current signing authority of A16, and A17 is none because they are assigned a weight of 0 in the new current signing threshold. For the rotation event to be valid it must be signed by A22, A16, and A17 in order to satisfy the prior next threshold for rotation authority and also must be signed by any two of A22, A25, and A26 in order to satisfy the new current signing authority for the event itself. This illustrates how reserved keypairs may be used exclusively for rotation authority and not for signing authority. ## Custodial Rotation Example @@ -783,21 +776,18 @@ Provided here is an illustrative example to help to clarify the pre-rotation pro The meaning of the column labels is as follows: -+ SN is the sequence number of the event. Each event uses two rows in the table. -+ Role is either Current (Crnt) or Next and indicates the role of the key list and threshold on that row. -+ Keys is the list of public keys denoted with indexed label of the keypair sequence. -+ Threshold is the threshold of signatures that must be satisfied for validity. - +* SN is the sequence number of the event. Each event uses two rows in the table. +* Role is either Current (Crnt) or Next and indicates the role of the key list and threshold on that row. +* Keys is the list of public keys denoted with indexed label of the keypair sequence. +* Threshold is the threshold of signatures that must be satisfied for validity. Commentary of each event follows: -+ (0) Inception: The private keys from current signing keypairs A0, A1, and A2 are held by the custodian of the identifier. The owner of the identifier provides the digests of the next rotation keypairs, H(A3), H(A4), and H(A5) to the custodian in order that the custodian may include them in the event and then sign the event. The owner holds the private keys from the next rotation keypairs A3, A4, and A5. A self-addressing AID would then be created by the formulation of the inception event. Once formed, the custodian controls the signing authority over the identifier by virtue of holding the associated private keys for the current key list. But the owner controls the rotation authority by virtue of holding the associated private keys for the next key list. Because the controller of the rotation authority may at their sole discretion revoke and replace the keys that hold signing authority, the owner, holder of the next private keys, is ultimately in control of the identifier so constituted by this inception event. - -+ (1) Rotation: The owner changes custodians with this event. The new custodian creates new current signing keypairs, A6, A7, and A8 and holds the associated private keys. The new custodian provides the public keys A6, A7, and A8 to the owner so that the owner can formulate and sign the rotation event that transfers signing authority to the new custodian. The owner exposes its rotation public keys, A3, A4, and A5 by including them in the new current key list. But the weights of those rotation keys in the new current signing threshold are all 0 so they have no signing authority. The owner creates a new set of next keypairs and includes their public key digests, H(A9), H(A10), H(A11) in the new next key list. The owner holds the associated private keys and thereby retains rotation authority. This event MUST be signed by any two of A3, A4, and A5 in order to satisfy the prior next threshold and also MUST be signed by any two A6, A7, and A8 in order to satisfy the new current signing threshold. The new current threshold and new next threshold clearly delineate that the new custodian now holds exclusive signing authority and owner continues to retain exclusive rotation authority. - -+ (2) Rotation: Change to yet another custodian following the same pattern as event \#1 +* (0) Inception: The private keys from current signing keypairs A0, A1, and A2 are held by the custodian of the identifier. The owner of the identifier provides the digests of the next rotation keypairs, H(A3), H(A4), and H(A5) to the custodian in order that the custodian may include them in the event and then sign the event. The owner holds the private keys from the next rotation keypairs A3, A4, and A5. A self-addressing AID would then be created by the formulation of the inception event. Once formed, the custodian controls the signing authority over the identifier by virtue of holding the associated private keys for the current key list. But the owner controls the rotation authority by virtue of holding the associated private keys for the next key list. Because the controller of the rotation authority may at their sole discretion revoke and replace the keys that hold signing authority, the owner, holder of the next private keys, is ultimately in control of the identifier so constituted by this inception event. +* (1) Rotation: The owner changes custodians with this event. The new custodian creates new current signing keypairs, A6, A7, and A8 and holds the associated private keys. The new custodian provides the public keys A6, A7, and A8 to the owner so that the owner can formulate and sign the rotation event that transfers signing authority to the new custodian. The owner exposes its rotation public keys, A3, A4, and A5 by including them in the new current key list. But the weights of those rotation keys in the new current signing threshold are all 0 so they have no signing authority. The owner creates a new set of next keypairs and includes their public key digests, H(A9), H(A10), H(A11) in the new next key list. The owner holds the associated private keys and thereby retains rotation authority. This event MUST be signed by any two of A3, A4, and A5 in order to satisfy the prior next threshold and also MUST be signed by any two A6, A7, and A8 in order to satisfy the new current signing threshold. The new current threshold and new next threshold clearly delineate that the new custodian now holds exclusive signing authority and owner continues to retain exclusive rotation authority. +* (2) Rotation: Change to yet another custodian following the same pattern as event \#1 # KERI Data Structures @@ -837,41 +827,29 @@ Because the order of appearance of fields is enforced in all KERI data structure The primary field labels are compact in that they use only one or two characters. KERI is meant to support resource-constrained applications such as supply chain or IoT (Internet of Things) applications. Compact labels better support resource-constrained applications in general. With compact labels, the over-the-wire verifiable signed serialization consumes a minimum amount of bandwidth. Nevertheless, without loss of generality, a one-to-one normative semantic overlay using more verbose expressive field labels may be applied to the normative compact labels after verification of the over-the-wire serialization. This approach better supports bandwidth and storage constraints on transmission while not precluding any later semantic post-processing. This is a well-known design pattern for resource-constrained applications. - ## Special Label Ordering Requirements - - ## Version String Field The version string, `v`, field MUST be the first field in any top-level KERI field map in which it appears. Typically the version string, `v`, field appears as the first top-level field in a KERI message body. This enables a RegEx stream parser to consistently find the version string in any of the supported serialization formats for KERI messages. The `v` field provides a regular expression target for determining the serialization format and size (character count) of a serialized KERI message body. A stream parser may use the version string to extract and deserialize (deterministically) any serialized KERI message body in a stream of serialized KERI messages. Each KERI message in a stream may use a different serialization type. The format of the version string is `KERIvvSSSShhhhhh_`. The first four characters `KERI` indicate the enclosing field map serialization. The next two characters, `vv` provide the lowercase hexadecimal notation for the major and minor version numbers of the version of the KERI specification used for the serialization. The first `v` provides the major version number and the second `v` provides the minor version number. For example, `01` indicates major version 0 and minor version 1 or in dotted-decimal notation `0.1`. Likewise `1c` indicates major version 1 and minor version decimal 12 or in dotted-decimal notation `1.12`. The next four characters `SSSS` indicate the serialization type in uppercase. The four supported serialization types are `JSON`, `CBOR`, `MGPK`, and `CESR` for the JSON, CBOR, MessagePack, and CESR serialization standards respectively {{JSOND}}{{RFC4627}}{{CBORC}}{{RFC8949}}{{MGPK}}{{CESR-ID}}. The next six characters provide in lowercase hexadecimal notation the total number of characters in the serialization of the KERI message body. The maximum length of a given KERI message body is thereby constrained to be *224 = 16,777,216* characters in length. The final character `-` is the version string terminator. This enables later versions of ACDC to change the total version string size and thereby enable versioned changes to the composition of the fields in the version string while preserving deterministic regular expression extractability of the version string. Although a given KERI serialization type may use field map delimiters or framing code characters that appear before (i.e. prefix) the version string field in a serialization, the set of possible prefixes is sufficiently constrained by the allowed serialization protocols to guarantee that a regular expression can determine unambiguously the start of any ordered field map serialization that includes the version string as the first field value. Given the version string, a parser may then determine the end of the serialization so that it can extract the full serialization (KERI message body) from the stream without first deserializing it or parsing it field-by-field. This enables performant stream parsing and off-loading of KERI message streams that include any or all of the supported serialization types interleaved in a single stream. - - - - ## SAID (Self-Addressing IDentifier) Fields - Some fields in KERI data structures may have for their value a SAID. In this context, `d` is short for digest, which is short for Self-Addressing IDentifier (SAID). A SAID follows the SAID protocol {{SAID-ID}}. Essentially a SAID is a Self-Addressing IDentifier (self-referential content addressable). A SAID is a special type of cryptographic digest of its encapsulating *field map* (block). The encapsulating block of a SAID is called a SAD (Self-Addressed Data). Using a SAID as a *field value* enables a more compact but secure representation of the associated block (SAD) from which the SAID is derived. Any nested field map that includes a SAID field (i.e. is, therefore, a SAD) may be compacted into its SAID. The uncompacted blocks for each associated SAID may be attached or cached to optimize bandwidth and availability without decreasing security. Each SAID provides a stable universal cryptographically verifiable and agile reference to its encapsulating block (serialized *field map*). Recall that a cryptographic commitment (such as a digital signature or cryptographic digest) on a given digest with sufficient cryptographic strength including collision resistance {{HCR}}{{QCHC}} is equivalent to a commitment to the block from which the given digest was derived. Specifically, a digital signature on a SAID makes a verifiable cryptographic non-repudiable commitment that is equivalent to a commitment on the full serialization of the associated block from which the SAID was derived. This enables reasoning about KERI data structures in whole or in part via their SAIDS in a fully interoperable, verifiable, compact, and secure manner. This also supports the well-known bow-tie model of Ricardian Contracts {{RC}}. This includes reasoning about the whole KERI data structure given by its top-level SAID, `d`, field as well as reasoning about any nested or attached data structures using their SAIDS. - - ## AID (Autonomic IDentifier) Fields Some fields, such as the `i` and `di` fields, MUST each have an AID (Autonomic IDentifier) as its value. An AID is a fully qualified Self-Certifying IDentifier (SCID) as described above {{KERI}}{{KERI-ID}}. An AID MUST be self-certifying. In this context, `i` is short for `ai`, which is short for the Autonomic IDentifier (AID). The AID given by the `i` field may also be thought of as a securely attributable identifier, authoritative identifier, authenticatable identifier, authorizing identifier, or authoring identifier.Another way of thinking about an `i` field is that it is the identifier of the authoritative entity to which a statement may be securely attributed, thereby making the statement verifiably authentic via a non-repudiable signature made by that authoritative entity as the Controller of the private key(s). - - - ### Namespaced AIDs + Because KERI is agnostic about the namespace for any particular AID, different namespace standards may be used to express KERI AIDs within AID fields in an ACDC. The examples below use the W3C DID namespace specification with the `did:keri` method {{DIDK-ID}}. But the examples would have the same validity from a KERI perspective if some other supported namespace was used or no namespace was used at all. The latter case consists of a bare KERI AID (identifier prefix). ToDo Explain agnosticism vis a vis namespaces @@ -879,7 +857,6 @@ ToDo Explain agnosticism vis a vis namespaces Version string namespaces the AIDs as KERI so don't need any namespacing on a per identifier basis. - ## Version String Field Get from ACDC @@ -888,7 +865,6 @@ Get from ACDC The `nt` field is next threshold for the next establishment event. - ## Common Normalized ACDC and KERI Labels `v` is the version string @@ -896,13 +872,8 @@ The `nt` field is next threshold for the next establishment event. `i` is a KERI identifier AID `a` is the data attributes or data anchors depending on the message type - - - # Seals - - ## Digest Seal ~~~json @@ -930,6 +901,7 @@ The `nt` field is next threshold for the next establishment event. ~~~ ## Event Seal + ~~~json { @@ -939,7 +911,6 @@ The `nt` field is next threshold for the next establishment event. } ~~~ - ## Last Establishment Event Seal ~~~json @@ -949,16 +920,12 @@ The `nt` field is next threshold for the next establishment event. ~~~ - - # Key Event Messages (Non-delegated) Because adding the `d` field SAID to every key event message type will break all the explicit test vectors. Its no additional pain to normalize the field ordering across all message types and seals. Originally all messages included an `i` field but that is not true anymore. So the changed field ordering is to put the fields that are common to all message types first in order followed by fields that are not common. The common fields are `v`, `t`, `d`. The newly revised messages and seals are shown below. - - ## Inception Event When the AID in the `i` field is a self-addressing self-certifying AID, the new Inception Event has two @@ -967,10 +934,8 @@ The derivation of the `d` and `i` fields is special. Both the `d` and `i` fields When the AID is not self-addressing, i.e. the `i` field derivation code is not a digest. Then the `i` is given its value and the `d` field is replaced with dummy characters `#` of the correct length and then the digest is computed. This is the standard SAID algorithm. - ## Inception Event Message Body - ~~~json { "v": "KERI10JSON0001ac_", @@ -1006,8 +971,6 @@ When the AID is not self-addressing, i.e. the `i` field derivation code is not a } ~~~ - - ## Rotation Event Message Body ~~~json @@ -1041,7 +1004,6 @@ When the AID is not self-addressing, i.e. the `i` field derivation code is not a } ~~~ - ## Interaction Event Message Body ~~~json @@ -1063,14 +1025,10 @@ When the AID is not self-addressing, i.e. the `i` field derivation code is not a } ~~~ - # Delegated Key Event Messages - ToDo in delegation section below. Delegated custodial example with partial rotation and using 0 fraction signing weights on exposed pre-rotated keys - - ## Delegated Inception Event Message Body ~~~json @@ -1109,9 +1067,6 @@ ToDo in delegation section below. Delegated custodial example with partial rotat } ~~~ - - - ## Delegated Rotation Event Message Body ~~~json @@ -1146,12 +1101,11 @@ ToDo in delegation section below. Delegated custodial example with partial rotat } ~~~ - # Receipt Messages ## Non-Transferable Prefix Signer Receipt Message Body -For receipts, the `d` field is the SAID of the associated event, not the receipt message itself. +For receipts, the `d` field is the SAID of the associated event, not the receipt message itself. ~~~json { @@ -1164,6 +1118,7 @@ For receipts, the `d` field is the SAID of the associated event, not the receipt ~~~ ## Transferable Prefix Signer Receipt Message Body + For receipts, the `d` field is the SAID of the associated event, not the receipt message itself. ~~~json @@ -1182,7 +1137,6 @@ For receipts, the `d` field is the SAID of the associated event, not the receipt } ~~~ - # Other Messages ## Query Message Message Body @@ -1277,7 +1231,6 @@ For receipts, the `d` field is the SAID of the associated event, not the receipt } ~~~ - ## Bare Message Body Reference to the anchoring seal is provided as an attachment to the bare, `bre` message. @@ -1300,7 +1253,6 @@ A bare, 'bre', message is a SAD item with an associated derived SAID in its 'd' } ~~~ - ## Exchange Message Body ~~~json @@ -1556,9 +1508,6 @@ A bare, 'bre', message is a SAD item with an associated derived SAID in its 'd' } ~~~ - - - # Appendix: Cryptographic Strength and Security ## Cryptographic Strength @@ -1567,35 +1516,29 @@ For crypto-systems with *perfect-security*, the critical design parameter is the An N-bit long base-2 random number has 2N different possible values. Given that no other information is available to an attacker with perfect security, the attacker may need to try every possible value before finding the correct one. Thus the number of attempts that the attacker would have to try maybe as much as 2N-1. Given available computing power, one can easily show that 128 is a large enough N to make brute force attack computationally infeasible. -Let's suppose that the adversary has access to supercomputers. Current supercomputers can perform on the order of one quadrillion operations per second. Individual CPU cores can only perform about 4 billion operations per second, but a supercomputer will parallelly employ many cores. A quadrillion is approximately 250 = 1,125,899,906,842,624. Suppose somehow an adversary had control over one million (220 = 1,048,576) supercomputers which could be employed in parallel when mounting a brute force attack. The adversary could then try 250 * 220 = 270 values per second (assuming very conservatively that each try only took one operation). -There are about 3600 * 24 * 365 = 313,536,000 = 2log2313536000=224.91 ~= 225 seconds in a year. Thus this set of a million super computers could try 250+20+25 = 295 values per year. For a 128-bit random number this means that the adversary would need on the order of 2128-95 = 233 = 8,589,934,592 years to find the right value. This assumes that the value of breaking the cryptosystem is worth the expense of that much computing power. Consequently, a cryptosystem with perfect security and 128 bits of cryptographic strength is computationally infeasible to break via brute force attack. +Let's suppose that the adversary has access to supercomputers. Current supercomputers can perform on the order of one quadrillion operations per second. Individual CPU cores can only perform about 4 billion operations per second, but a supercomputer will parallelly employ many cores. A quadrillion is approximately 250 = 1,125,899,906,842,624. Suppose somehow an adversary had control over one million (220 = 1,048,576) supercomputers which could be employed in parallel when mounting a brute force attack. The adversary could then try 250 *220 = 270 values per second (assuming very conservatively that each try only took one operation). +There are about 3600* 24 * 365 = 313,536,000 = 2log2313536000=224.91 ~= 225 seconds in a year. Thus this set of a million super computers could try 250+20+25 = 295 values per year. For a 128-bit random number this means that the adversary would need on the order of 2128-95 = 233 = 8,589,934,592 years to find the right value. This assumes that the value of breaking the cryptosystem is worth the expense of that much computing power. Consequently, a cryptosystem with perfect security and 128 bits of cryptographic strength is computationally infeasible to break via brute force attack. ## Information Theoretic Security and Perfect Security The highest level of cryptographic security with respect to a cryptographic secret (seed, salt, or private key) is called *information-theoretic security* {{ITPS}}. A cryptosystem that has this level of security cannot be broken algorithmically even if the adversary has nearly unlimited computing power including quantum computing. It must be broken by brute force if at all. Brute force means that in order to guarantee success the adversary must search for every combination of key or seed. A special case of *information-theoretic security* is called *perfect-security* {{ITPS}}. *Perfect-security* means that the ciphertext provides no information about the key. There are two well-known cryptosystems that exhibit *perfect security*. The first is a *one-time-pad* (OTP) or Vernum Cipher {{OTP}}{{VCphr}}, the other is *secret splitting* {{SSplt}}, a type of secret sharing {{SShr}} that uses the same technique as a *one-time-pad*. - - - - # Conventions and Definitions {::boilerplate bcp14-tagged} - # Security Considerations TODO Security - # IANA Considerations This document has no IANA actions. - --- back # Acknowledgments + {:numbered="false"} KERI Community at the WebOfTrust Github project. diff --git a/docs/specs/references/oid4vp-1.0.md b/docs/specs/references/oid4vp-1.0.md new file mode 100644 index 0000000..7934b3e --- /dev/null +++ b/docs/specs/references/oid4vp-1.0.md @@ -0,0 +1,92 @@ +# OpenID for Verifiable Presentations 1.0 (OID4VP) + +**Status:** OpenID Final Specification (9 July 2025) +**URL:** https://openid.net/specs/openid-4-verifiable-presentations-1_0.html +**Authors:** O. Terbu (MATTR), T. Lodderstedt (SPRIND), K. Yasuda +**Raw text:** `oid4vp-1.0.txt` (full spec, 3,834 lines) + +## Overview + +OID4VP extends OAuth 2.0 to enable Wallets to present Verifiable Credentials +and Verifiable Presentations to Verifiers. It introduces the `vp_token` +response type, `direct_post` response mode, and the `transaction_data` +mechanism for authorized transactions. + +## Key Parameters + +### Authorization Request (§5.1) + +| Parameter | Requirement | Description | +|-----------|-------------|-------------| +| `client_id` | REQUIRED | Verifier identifier (with Client Identifier Prefix) | +| `nonce` | REQUIRED | Fresh cryptographically random value per request (§14.1) | +| `response_type` | REQUIRED | `vp_token` or `vp_token id_token` | +| `response_mode` | OPTIONAL | `direct_post` or `direct_post.jwt` | +| `dcql_query` | CONDITIONAL | Digital Credentials Query Language query | +| `transaction_data` | OPTIONAL | Array of base64url-encoded JSON objects (§8.4) | + +### Authorization Response (§8) + +| Parameter | Description | +|-----------|-------------| +| `vp_token` | Contains one or more Verifiable Presentations | +| `presentation_submission` | Maps credentials to query (deprecated in favor of DCQL) | + +## Transaction Data (§8.4) + +Each `transaction_data` object MUST contain: + +| Parameter | Requirement | Description | +|-----------|-------------|-------------| +| `type` | REQUIRED | String identifying the transaction data type | +| `credential_ids` | REQUIRED | Array of credential query IDs for authorization | + +- Wallet MUST return error on unrecognized transaction data types (§5.1). +- Wallet MUST reject `transaction_data` if it doesn't support the parameter. +- Wallet MUST include representation/reference to data in the credential + presentation (§8.4). + +## SD-JWT VC Credential Format (Appendix B.3) + +### Format Identifier + +- `dc+sd-jwt` (aligned with SD-JWT-VC draft-15 media type) + +### Transaction Data in KB-JWT (§B.3.3) + +| KB-JWT Claim | Requirement | Description | +|-------------|-------------|-------------| +| `nonce` | REQUIRED | Value from Authorization Request nonce | +| `aud` | REQUIRED | Value of client_id (or `origin:` prefix for DC API) | +| `iat` | REQUIRED | Issued-at timestamp | +| `sd_hash` | REQUIRED | Hash over the SD-JWT before KB-JWT | +| `transaction_data_hashes` | CONDITIONAL | Array of base64url hashes over transaction_data strings | +| `transaction_data_hashes_alg` | CONDITIONAL | Hash algorithm used (default: `sha-256`) | + +### Presentation Response (§B.3.6) + +- SD-JWT+KB compact serialization: `~~...~~` +- KB-JWT provides Holder binding and audience/nonce binding. + +## Security Requirements (§14) + +### Replay Prevention (§14.1) + +- Verifier MUST create fresh nonce with sufficient entropy per request. +- Verifier MUST validate nonce in every VP in the response. +- Verifier MUST validate `aud` matches its client_id. + +### Session Fixation (§14.2) + +- Response URI MUST be validated against registered URIs. + +## Harbour Usage + +- Harbour uses OID4VP `transaction_data` for delegated signing flows. +- KB-JWT carries `transaction_data_hashes` + `_alg` for integrity binding. +- `DelegatedSignatureEvidence` in LinkML maps: + - `transaction_data` → OID4VP transaction_data object (decoded JSON) + - `delegatedTo` → conceptually maps to `client_id` / KB-JWT `aud` +- Harbour hashes decoded canonical JSON (content integrity), while OID4VP + hashes base64url transport strings (transport binding) — different layers. +- CSC Data Model `signatureRequest` triggers OID4VP flow. diff --git a/docs/specs/references/sd-jwt-rfc9901.md b/docs/specs/references/sd-jwt-rfc9901.md new file mode 100644 index 0000000..108196c --- /dev/null +++ b/docs/specs/references/sd-jwt-rfc9901.md @@ -0,0 +1,99 @@ +# RFC 9901: Selective Disclosure for JSON Web Tokens (SD-JWT) + +**Status:** Internet Standards Track (Proposed Standard) +**Published:** November 2025 +**URL:** https://www.rfc-editor.org/rfc/rfc9901 +**Datatracker:** https://datatracker.ietf.org/doc/rfc9901/ +**Authors:** D. Fett (Authlete), K. Yasuda (Keio University), B. Campbell (Ping Identity) + +## Overview + +SD-JWT defines a mechanism for selective disclosure of individual elements +of a JSON payload within a JWS. The primary use case is selective disclosure +of JWT claims: an Issuer creates a signed JWT containing digests of +selectively disclosable claims, and the Holder chooses which claims to +reveal to a Verifier. + +## Key Concepts + +### SD-JWT Structure (§4) + +- **SD-JWT** = Issuer-signed JWT + zero or more Disclosures +- **SD-JWT+KB** = SD-JWT + Key Binding JWT (proves Holder possession) +- Compact serialization: `~~~...~~` +- SD-JWT+KB serialization: `~~...~~` + +### Disclosures (§4.2) + +- Base64url-encoded JSON array: `[salt, claim_name, claim_value]` + (claim_name omitted for array elements) +- Hash of Disclosure is embedded in the JWT payload via `_sd` array. +- Digest computation: `base64url(hash(base64url(Disclosure)))` (§4.2.3) + +### Hash Function Claim — `_sd_alg` (§4.1.1) + +- OPTIONAL. Defaults to `sha-256`. +- If present, specifies the hash algorithm for Disclosure digests. + +### Key Binding JWT (§4.3) + +- `typ` header: `kb+jwt` +- Required claims: `iat`, `aud`, `nonce`, `sd_hash` +- `sd_hash`: hash over the SD-JWT string up to and including the last `~` + before the KB-JWT. +- Proves the presenter controls the private key referenced by `cnf`. + +### Confirmation Claim — `cnf` (§4.1.2) + +- When Key Binding is used, SD-JWT MUST contain `cnf` claim. +- `cnf` contains the Holder's public key (typically as `jwk`). + +## Verification Algorithm (§7) + +### Issuer-side (§7.1) + +1. Verify JWS signature on the Issuer-signed JWT. +2. Check `_sd_alg` (if present) is an accepted algorithm. + +### Holder processing (§7.2) + +1. Select which Disclosures to include in the presentation. +2. Optionally create a Key Binding JWT if required. + +### Verifier-side (§7.3) + +1. Separate the SD-JWT into JWT, Disclosures, and optional KB-JWT. +2. Verify the JWT signature. +3. For each Disclosure: + a. Compute its digest. + b. Find the digest in `_sd` arrays within the JWT payload. + c. Replace the digest with the Disclosure's claim. +4. If Key Binding required: + a. Verify KB-JWT signature against `cnf` key. + b. Verify `sd_hash` matches the presented SD-JWT. + c. Check `aud`, `nonce`, `iat` per policy. + +## Security Considerations (§9) + +| Topic | Requirement | +|-------|-------------| +| Signing | Issuer-signed JWT MUST be signed; `none` algorithm MUST NOT be used (§9.1) | +| Salt entropy | Salt MUST have at least 128 bits of entropy (§9.3) | +| Hash algorithm | SHA-256 or stronger RECOMMENDED (§9.4) | +| Key Binding | When enforced, Holder MUST prove possession (§9.5) | +| Forwarding | Without Key Binding, SD-JWTs are bearer credentials (§9.9) | +| Explicit typing | `typ` header SHOULD be used to prevent cross-protocol attacks (§9.11) | + +## Claims That MUST NOT Be Selectively Disclosable + +The specification does not mandate which claims are or aren't disclosable — +that is left to the credential profile. However, SD-JWT-VC (draft-15) defines +that `credentialStatus` and `@context` SHOULD NOT be selectively disclosable. + +## Harbour Usage + +- Harbour uses SD-JWT+KB for credential presentations (VP). +- KB-JWT provides Holder binding via P-256 key pair. +- `cnf` claim carries the Holder's public key. +- `transaction_data_hashes` in KB-JWT for OID4VP delegation flows. +- Selective disclosure annotations in LinkML map to SD-JWT `_sd` arrays. diff --git a/docs/specs/references/sd-jwt-vc.md b/docs/specs/references/sd-jwt-vc.md index 441748b..50bf741 100644 --- a/docs/specs/references/sd-jwt-vc.md +++ b/docs/specs/references/sd-jwt-vc.md @@ -1,20 +1,23 @@ # IETF SD-JWT-VC — SD-JWT-based Verifiable Digital Credentials -**Status:** Internet Draft (draft-ietf-oauth-sd-jwt-vc-14, Feb 2026) +**Status:** Internet Draft (draft-ietf-oauth-sd-jwt-vc-15, Feb 2026) **URL:** https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/ +**HTML:** https://www.ietf.org/archive/id/draft-ietf-oauth-sd-jwt-vc-15.html **Base:** RFC 9901 (SD-JWT) ## Key Normative Requirements ### Relationship to W3C VCDM (§11) + SD-JWT-VC does NOT utilize W3C VCDM v1.0, v1.1, or v2.0. It uses flat JWT claims rather than JSON-LD structure. There is no `@context` or `type` array. ### Required/Optional Claims + | Claim | Requirement | Notes | |-------|-------------|-------| | `vct` | REQUIRED | Credential type (URI string, replaces `type` array) | -| `iss` | OPTIONAL (draft-14) | Was REQUIRED in draft-08. Can use x5c instead | +| `iss` | OPTIONAL (since draft-14) | Was REQUIRED in draft-08. Can use x5c instead | | `iat` | OPTIONAL | Issuance time (selectively disclosable) | | `nbf` | OPTIONAL | Not before (not selectively disclosable) | | `exp` | OPTIONAL | Expiration (MUST NOT be selectively disclosable) | @@ -23,6 +26,7 @@ claims rather than JSON-LD structure. There is no `@context` or `type` array. | `status` | OPTIONAL | MUST NOT be selectively disclosable | ### `typ` Header Change + | Version | `typ` value | Media type | |---------|-------------|------------| | draft-08 | `vc+sd-jwt` | `application/vc+sd-jwt` | @@ -32,20 +36,24 @@ Renamed to avoid conflict with W3C VC-JOSE-COSE's `application/vc+sd-jwt` which carries full JSON-LD payload. Verifiers SHOULD accept both during transition. ### Status (§3.2) + The `status` claim MUST NOT be selectively disclosable. Uses `status_list` sub-object with `idx` (integer) and `uri` (status list URL). ### Key Binding (§4, via RFC 9901) + - KB-JWT REQUIRED claims: `iat`, `aud`, `nonce`, `sd_hash` - `sd_hash` computed over US-ASCII bytes of entire SD-JWT before KB-JWT: `~~...~~` - KB-JWT `typ` header: `kb+jwt` ### Custom Claims (§11) + Custom claims are allowed. `evidence` is not defined by SD-JWT-VC but can be added as a custom claim and MAY be selectively disclosable. ## Mapping to W3C VCDM + | W3C VCDM | SD-JWT-VC | Notes | |----------|-----------|-------| | `type` array | `vct` | URI string, not array | diff --git a/docs/specs/references/vc-data-model-2.0.md b/docs/specs/references/vc-data-model-2.0.md new file mode 100644 index 0000000..bfd7af2 --- /dev/null +++ b/docs/specs/references/vc-data-model-2.0.md @@ -0,0 +1,114 @@ +# W3C Verifiable Credentials Data Model v2.0 + +**Status:** W3C Recommendation +**URL:** https://www.w3.org/TR/vc-data-model-2.0/ +**JSON-LD Context:** https://www.w3.org/ns/credentials/v2 +**Vocabulary:** https://www.w3.org/2018/credentials/ + +## Key Normative Requirements + +### @context (§4.3) + +- MUST be an ordered set where the first item is `https://www.w3.org/ns/credentials/v2`. +- Subsequent items MUST be URLs or objects processable as JSON-LD contexts. + +### Identifiers — id (§4.4) + +- OPTIONAL. If present, MUST be a single URL (may be dereferenceable). +- Applies to VC, VP, and credentialSubject. +- RECOMMENDED: URL that resolves to machine-readable info about the id. + +### Types — type (§4.5) + +- MUST be present. Maps to `@type` in JSON-LD. +- MUST include `VerifiableCredential` for credentials. +- Values MUST be terms or absolute URL strings resolvable via @context. + +### Issuer (§4.7) + +- A verifiable credential MUST have an `issuer` property. +- Value MUST be either a URL or an object containing an `id` property + whose value is a URL. +- The issuer is expected to be the entity that asserts the claims. + +### Credential Subject (§4.8) + +- A verifiable credential MUST contain a `credentialSubject` property. +- Value MUST be one or more objects, each describing claims about a subject. +- Each object MAY have an `id` property (URL identifying the subject). + +### Validity Period (§4.9) + +- `validFrom` — OPTIONAL. If present, value MUST be an xsd:dateTimeStamp + (ISO 8601 with mandatory timezone offset). + Represents the earliest date/time the credential is valid. +- `validUntil` — OPTIONAL. If present, value MUST be an xsd:dateTimeStamp. + Represents the latest date/time the credential is valid. +- Both properties are OPTIONAL per the base spec; profiles MAY make them + REQUIRED (e.g., Harbour profile requires validFrom). + +### Status (§4.10) + +- `credentialStatus` — OPTIONAL. +- If present, value MUST be one or more objects, each containing: + - `id` — MUST be a URL identifying the status information. + - `type` — MUST be present, identifying the status mechanism. +- The status mechanism is extensible (e.g., BitstringStatusList, CRSet). +- Verifiers SHOULD check credential status during verification. + +### Data Schemas (§4.11) + +- `credentialSchema` — OPTIONAL. +- If present, each entry MUST have `id` (URL) and `type`. + +### Evidence (§5.6) + +- `evidence` — OPTIONAL (0..*). +- Provides information about the process/evidence the issuer used + when evaluating the claims. +- Each evidence object MUST specify its `type`. +- Evidence objects MAY contain arbitrary additional properties. + +### Securing Mechanisms (§4.12) + +- A conforming document MUST be secured by at least one securing mechanism. +- Two approaches specified: + - **Embedded proof** — Verifiable Credential Data Integrity 1.0 (`proof` property) + - **Enveloping proof** — VC-JOSE-COSE (JWT/SD-JWT/COSE wrapping) + +### Media Types (§6.2) + +| Media Type | Purpose | +|------------|---------| +| `application/vc` | Verifiable Credential (JSON-LD) | +| `application/vp` | Verifiable Presentation (JSON-LD) | + +## Property Summary + +| Property | Requirement | Type | Section | +|----------|-------------|------|---------| +| `@context` | MUST | ordered set of URLs/objects | §4.3 | +| `id` | OPTIONAL | URL | §4.4 | +| `type` | MUST | set of strings | §4.5 | +| `name` | OPTIONAL | string or language map | §4.6 | +| `description` | OPTIONAL | string or language map | §4.6 | +| `issuer` | MUST | URL or object with id | §4.7 | +| `credentialSubject` | MUST | object or array of objects | §4.8 | +| `validFrom` | OPTIONAL | xsd:dateTimeStamp | §4.9 | +| `validUntil` | OPTIONAL | xsd:dateTimeStamp | §4.9 | +| `credentialStatus` | OPTIONAL | object or array of objects | §4.10 | +| `credentialSchema` | OPTIONAL | object or array of objects | §4.11 | +| `evidence` | OPTIONAL | object or array of objects | §5.6 | +| `refreshService` | OPTIONAL | object or array of objects | §5.4 | +| `termsOfUse` | OPTIONAL | object or array of objects | §5.5 | + +## Harbour Profile Deviations + +Harbour makes the following properties stricter than the base spec: + +| Property | W3C Base | Harbour Profile | +|----------|----------|-----------------| +| `issuer` | MUST | MUST (same) | +| `validFrom` | OPTIONAL | MUST (stricter) | +| `credentialStatus` | OPTIONAL | MUST (stricter, range: CRSetEntry) | +| `evidence` | OPTIONAL | OPTIONAL (same, but MUST on LegalPerson/NaturalPerson credentials) | diff --git a/docs/specs/references/vc-jose-cose.md b/docs/specs/references/vc-jose-cose.md index 6afa72f..0cd90d8 100644 --- a/docs/specs/references/vc-jose-cose.md +++ b/docs/specs/references/vc-jose-cose.md @@ -6,11 +6,13 @@ ## Key Normative Requirements ### Payload Structure (§3.1) + - The entire VC JSON-LD document IS the JWT Claims Set (enveloping proof model). - The JWT Claim Names `vc` and `vp` MUST NOT be present (§1.1.2.1, §3.1.3). - Implementations MUST support JWS compact serialization; JSON serialization NOT RECOMMENDED. ### Media Types (§6.1) + | Media Type | Purpose | |------------|---------| | `application/vc+jwt` | JWT-secured credentials | @@ -23,6 +25,7 @@ **Note:** No `+ld+` media types exist (e.g., `vc+ld+jwt` is NOT valid). ### `typ` Header (§3.1.1, §3.1.2, §3.2.1, §3.2.2) + | Context | `typ` SHOULD be | |---------|-----------------| | JOSE VC | `vc+jwt` | @@ -31,6 +34,7 @@ | SD-JWT VP | `vp+sd-jwt` | ### Claim/Property Conflict Avoidance (§3.1.3) + | JWT Claim | VC Property | Guidance | |-----------|-------------|----------| | `iss` | `issuer` | SHOULD NOT conflict | @@ -42,15 +46,19 @@ Use of `nbf` is NOT RECOMMENDED (§3.1.3). ### SD-JWT Non-Disclosable Properties (§3.2.1) + Properties that SHOULD NOT be selectively disclosable: + - `@context`, `type`, `credentialStatus`, `credentialSchema`, `relatedResource` ### Key Discovery (§4.1, §4.2) + - `kid` MUST be present when key is expressed as DID URL (§4.1.1). - Verification method type MUST be `JsonWebKey`; key MUST be in `publicKeyJwk` (§4.2). - `cnf` MAY identify proof-of-possession key per RFC 7800 (§4.1.3). ### Verification (§5) + - Verified document MUST be well-formed compact JSON-LD per VCDM2. - All claims for `typ` MUST be present and evaluated per validation policies. - Claims not understood MUST be ignored. diff --git a/examples/README.md b/examples/README.md index 4c0e0c4..4de28c2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -385,7 +385,6 @@ import { verifySdJwtVp, signJwt } from '@reachhaven/harbour-credentials'; | [`gaiax/legal-person-credential.json`](gaiax/legal-person-credential.json) | `legal-person-credential.json` | `gxParticipant` with registration number, addresses | | [`gaiax/natural-person-credential.json`](gaiax/natural-person-credential.json) | `natural-person-credential.json` | `gxParticipant` with `gx:Participant` | - ### Signed artifacts (`signed/`) For each credential, the signer produces: diff --git a/examples/did-ethr/README.md b/examples/did-ethr/README.md index cbb840c..0e0b73d 100644 --- a/examples/did-ethr/README.md +++ b/examples/did-ethr/README.md @@ -24,9 +24,11 @@ Each document follows the `did:ethr` resolved format: ## Controller All identities are governed by a smart contract controller: -``` + +```text did:ethr:0x14a34:0xC0FFEEbabe000000000000000000000000000001 ``` + This is a placeholder address — the actual contract will be deployed to Base. ## Key Management @@ -37,6 +39,7 @@ The secp256k1 controller key provides blockchain-native identity ownership. ## Usage These DID documents are referenced by: + - `examples/*.json` — Credential examples (issuer, subject, holder) - `examples/gaiax/*.json` — Gaia-X specific credential examples - `tests/` — Test fixtures and assertions diff --git a/linkml/harbour-core-credential.yaml b/linkml/harbour-core-credential.yaml index 1f875df..b69b6f8 100644 --- a/linkml/harbour-core-credential.yaml +++ b/linkml/harbour-core-credential.yaml @@ -8,6 +8,44 @@ description: > and credential types are defined in separate domain schemas (e.g. harbour-gx-credential.yaml). +# ============================================================================ +# SPECIFICATION REFERENCES +# ============================================================================ +# [VCDM2] W3C Verifiable Credentials Data Model v2.0 +# https://www.w3.org/TR/vc-data-model-2.0/ +# [DID-CORE] W3C Decentralized Identifiers (DIDs) v1.0 +# https://www.w3.org/TR/did-core/ +# [VC-JOSE-COSE] W3C Securing Verifiable Credentials using JOSE and COSE +# https://www.w3.org/TR/vc-jose-cose/ +# [SD-JWT] RFC 9901: Selective Disclosure for JWTs +# https://www.rfc-editor.org/rfc/rfc9901 +# [SD-JWT-VC] SD-JWT-based Verifiable Credentials (draft-15) +# https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/ +# [OID4VP] OpenID for Verifiable Presentations 1.0 (Final) +# https://openid.net/specs/openid-4-verifiable-presentations-1_0.html +# [SCHEMA-ORG] schema.org vocabulary +# https://schema.org/ +# [VC-CTX] W3C VC v2 JSON-LD Context +# https://www.w3.org/ns/credentials/v2 +# +# LINKML DESIGN DECISIONS +# ============================================================================ +# The w3c-vc.yaml shim is necessary because LinkML cannot import external +# OWL ontologies — see [LINKML-1950] (closed: wrapping is the pattern). +# https://github.com/linkml/linkml/issues/1950 +# +# Generator workarounds (in generate_artifacts.py): +# - cred:issuer nodeKind patched to sh:IRIOrLiteral because LinkML maps +# range: string to sh:Literal, but [VC-CTX] defines issuer as @type: @id. +# - sh:class linkml:Any removed — see [LINKML-2914] (open). +# https://github.com/linkml/linkml/issues/2914 +# - Imported cred: terms excluded from JSON-LD context to avoid redefining +# @protected terms already provided by [VC-CTX]. +# - "type": "@type" manually injected — LinkML cannot emit this alias +# without declaring a "type" slot that would conflict with [VC-CTX]'s +# @protected definition of "type". +# ============================================================================ + prefixes: linkml: https://w3id.org/linkml/ harbour: https://w3id.org/reachhaven/harbour/credentials/v1/ @@ -21,111 +59,117 @@ default_range: string imports: - linkml:types + - ./w3c-vc slots: # --- Identity Slots --- - # Spec: VCDM2 §4.4 — "id" is OPTIONAL on VC/VP/credentialSubject. + # [VCDM2] §4.4 — "id" is OPTIONAL on VC/VP/credentialSubject. # If present, MUST be a URL (incl. DIDs, urn:uuid:). # Maps to JSON-LD @id. - # Spec: DID-Core §3.1 — DID is a URI scheme. + # [DID-CORE] §3.1 — DID is a URI scheme (did::). id: description: The stable identifier for the entity (becomes @id in JSON-LD). identifier: true range: uri - # NOTE: "type" is intentionally NOT modeled as a slot. - # Spec: VCDM2 §4.5 — "type" MUST be present, maps to @type. Values MUST be + # DESIGN DECISION: "type" is intentionally NOT modeled as a slot. + # [VCDM2] §4.5 — "type" MUST be present, maps to @type. Values MUST be # terms or absolute URL strings resolvable via @context. - # W3C VC v2 context defines "type": "@type", which correctly maps JSON "type" - # to rdf:type IRIs. Declaring a LinkML slot with slot_uri: rdf:type would - # generate a JSON-LD context entry that overrides the W3C alias, turning type - # values into xsd:anyURI literals instead of IRIs. - - # --- DID Slots --- - # Spec: DID-Core §4.2 — controller is a URI or set of URIs. + # [VC-CTX] defines "type": "@type", which correctly maps JSON "type" + # to rdf:type IRIs. Declaring a LinkML slot with slot_uri: rdf:type would + # generate a JSON-LD context entry that overrides this W3C alias, turning + # type values into xsd:anyURI literals instead of IRIs. + # The "type": "@type" mapping is injected post-generation in + # generate_artifacts.py (see LINKML DESIGN DECISIONS above). + + # --- DID Document Slots --- + # [DID-CORE] §4.2 — controller is a URI or set of URIs identifying the + # entity authorized to make changes to the DID document. # In practice always a DID (did:ethr:..., did:key:..., etc.). controller: slot_uri: https://www.w3.org/ns/did#controller range: uri - # Spec: DID-Core §5.4 — serviceEndpoint can be URI, map, or set. + # [DID-CORE] §5.4 — serviceEndpoint can be a URI, map, or set. + # Each service entry MUST have id, type, and serviceEndpoint. serviceEndpoint: slot_uri: https://www.w3.org/ns/did#serviceEndpoint range: Any required: true # --- Revocation Registry (CRSet) Slots --- - # Spec: VCDM2 §4.10 — credentialStatus MUST have id and type; statusPurpose - # defined by BitstringStatusList. SD-JWT-VC uses "status" claim (MUST NOT - # be selectively disclosable, draft-14 §3.2). + # [VCDM2] §4.10 — credentialStatus MUST have id and type; statusPurpose + # defined by BitstringStatusList. CRSet is the Harbour-specific mechanism. + # [SD-JWT-VC] §3.2 — "status" claim MUST NOT be selectively disclosable. statusPurpose: slot_uri: cs:statusPurpose range: string required: false + # Harbour-specific: endpoint IRI for the CRSet revocation registry. registryEndpoint: description: Service endpoint IRI for the CRSet. slot_uri: harbour:registryEndpoint range: uri required: false + # Harbour-specific: smart contract URN for on-chain CRSet operations. contractURN: slot_uri: harbour:contractURN range: uri required: false + # Harbour-specific: source repository for the CRSet implementation. sourceRepository: slot_uri: harbour:sourceRepository range: uri required: false + # Harbour-specific: reference implementation for CRSet verification. implementation: slot_uri: harbour:implementation range: uri required: false # --- Trust Anchor / Organization Slots --- + # [SCHEMA-ORG] — https://schema.org/ContactPoint contactType: slot_uri: schema:contactType range: string + # [SCHEMA-ORG] — https://schema.org/name name: description: A human-readable name for the entity. slot_uri: schema:name range: string + # [SCHEMA-ORG] — https://schema.org/description description: description: A human-readable description of the entity. slot_uri: schema:description range: string + # [SCHEMA-ORG] — https://schema.org/url url: description: A URL associated with the entity. slot_uri: schema:url range: uri + # [SCHEMA-ORG] — https://schema.org/email email: description: An email address. slot_uri: schema:email range: string + # [SCHEMA-ORG] — https://schema.org/contactPoint contactPoint: description: A contact point for the entity. slot_uri: schema:contactPoint range: string - # --- Credential / Evidence Slots --- - # Spec: VCDM2 §5.6 — evidence is OPTIONAL (0..*), each object MUST have type. - # SD-JWT-VC does NOT define evidence; custom claims allowed (draft-14 §11). - evidence: - slot_uri: cred:evidence - range: Evidence - multivalued: true - required: false - - # Spec: VC-JOSE-COSE §6.1 — media types: application/vp+jwt, application/vp+sd-jwt. + # [VC-JOSE-COSE] §6.1 — media types: application/vp+jwt, application/vp+sd-jwt. # Payload is the full VC JSON-LD as JWT claims set (§3.1). - # SD-JWT VP: RFC 9901 §4.3 — KB-JWT appended after disclosures. + # [SD-JWT] RFC 9901 §4.3 — KB-JWT appended after disclosures. verifiablePresentation: description: > A Verifiable Presentation embedded as evidence. In examples this is @@ -136,14 +180,14 @@ slots: required: false # --- Delegated Signature Evidence Slots --- - # Spec: OID4VP §5.1 — transaction_data is request param (array of base64url JSON). - # OID4VP §B.3.3 — KB-JWT carries transaction_data_hashes + _alg. - # OID4VP §8.4 — Wallet MUST process each transaction_data object. - # CSC-DM v1.0.0 — signatureRequest triggers OID4VP flow with transaction_data. - # Harbour Delegation Spec §3 — transaction data object with type, credential_ids, - # nonce, iat, txn. Hash computed over canonical JSON (sorted keys, no whitespace). - # NOTE: OID4VP hashes base64url transport string; Harbour hashes decoded canonical - # JSON. Both serve different layers (transport binding vs content integrity). + # [OID4VP] §5.1 — transaction_data is request param (array of base64url JSON). + # [OID4VP] §B.3.3 — KB-JWT carries transaction_data_hashes + _alg. + # [OID4VP] §8.4 — Wallet MUST process each transaction_data object. + # [CSC-DM] v1.0.0 — signatureRequest triggers OID4VP flow with transaction_data. + # Harbour Delegation Spec §3 — transaction data object with type, credential_ids, + # nonce, iat, txn. Hash computed over canonical JSON (sorted keys, no whitespace). + # NOTE: OID4VP hashes base64url transport string; Harbour hashes decoded canonical + # JSON. Both serve different layers (transport binding vs content integrity). transaction_data: description: > OID4VP-aligned transaction data object (§8.4). Contains action type, @@ -154,41 +198,14 @@ slots: range: Any required: false - # Spec: Conceptually maps to OID4VP client_id → KB-JWT aud (RFC 9901 §4.3) - # or Data Integrity proof.domain (VC-DI §2.1). + # [OID4VP] — conceptually maps to client_id → KB-JWT aud. + # [SD-JWT] RFC 9901 §4.3 — KB-JWT aud claim identifies the intended verifier. delegatedTo: description: DID of the signing service executing on behalf of the user. slot_uri: harbour:delegatedTo range: uri required: false - # --- Credential Envelope Slots --- - # Spec: VCDM2 §4.7 — issuer MUST exist; MUST be URL or object with id. - # VC-JOSE §3.1.3 — iss SHOULD NOT conflict with issuer. - # SD-JWT-VC draft-14 — iss OPTIONAL (can use x5c); draft-08 REQUIRED. - # Harbour profile: REQUIRED (stricter than SD-JWT-VC draft-14). - issuer: - slot_uri: cred:issuer - range: string - required: true - description: DID of the credential issuer. - - # --- W3C VC v2 Envelope Slots (harbour constrains these) --- - # Spec: VCDM2 §4.9 — validFrom is OPTIONAL, xsd:dateTime with mandatory TZ. - # SD-JWT-VC maps to iat (OPTIONAL, selectively disclosable) or nbf (OPTIONAL). - # Harbour profile: REQUIRED (stricter than base spec). - validFrom: - slot_uri: cred:validFrom - range: datetime - required: true - - # Spec: VCDM2 §4.9 — validUntil is OPTIONAL, xsd:dateTime. - # SD-JWT-VC maps to exp (OPTIONAL, MUST NOT be selectively disclosable). - validUntil: - slot_uri: cred:validUntil - range: datetime - required: false - classes: Any: class_uri: linkml:Any @@ -197,21 +214,21 @@ classes: # ========================================== # 1. ROOT DOCUMENT # ========================================== - # Spec: DID-Core §4 — DID Document is a set of data describing the DID subject. + # [DID-CORE] §4 — DID Document is a set of data describing the DID subject. # Properties: id (REQUIRED), controller, verificationMethod, service, etc. DIDDocument: class_uri: https://www.w3.org/ns/did#DIDDocument slots: - controller attributes: - # Spec: DID-Core §5.4 — service is OPTIONAL, each entry MUST have id, type, + # [DID-CORE] §5.4 — service is OPTIONAL, each entry MUST have id, type, # and serviceEndpoint. service values MUST be unique. service: slot_uri: https://www.w3.org/ns/did#service multivalued: true inlined: true range: ServiceUnion - # Spec: DID-Core §5.3.1 — verificationMethod entries MUST have id, type, + # [DID-CORE] §5.3.1 — verificationMethod entries MUST have id, type, # controller, and key material (publicKeyJwk or publicKeyMultibase). # Harbour models a subset (id, controller, blockchainAccountId). verificationMethod: @@ -222,7 +239,7 @@ classes: # ========================================== # 2. SERVICES # ========================================== - # Spec: DID-Core §5.4 — services express ways of communicating with the DID + # [DID-CORE] §5.4 — services express ways of communicating with the DID # subject. Each service MUST have id, type, serviceEndpoint. ServiceUnion: union_of: @@ -274,7 +291,7 @@ classes: - email # Harbour-specific: CRSet revocation registry service endpoint. - # Spec: VCDM2 §4.10 — credentialStatus mechanisms are extensible. CRSet is a + # [VCDM2] §4.10 — credentialStatus mechanisms are extensible. CRSet is a # Harbour-defined mechanism (not BitstringStatusList or StatusList2021). CRSetRevocationRegistryService: class_uri: harbour:CRSetRevocationRegistryService @@ -297,13 +314,13 @@ classes: # ========================================== # 3. CREDENTIAL TYPES # ========================================== - # Spec: VCDM2 §4 — VerifiableCredential MUST have @context, type, + # [VCDM2] §4 — VerifiableCredential MUST have @context, type, # credentialSubject, issuer. MAY have id, validFrom, validUntil, # credentialStatus, evidence, credentialSchema, relatedResource, etc. # Harbour credential types add mandatory credentialStatus (CRSetEntry) # and optional evidence to W3C VerifiableCredential. - # Spec: VC-JOSE §3.1.1 — full VC JSON-LD becomes JWT payload (no vc wrapper). - # Spec: SD-JWT-VC draft-14 §11 — SD-JWT-VC does NOT use W3C VCDM; flat claims. + # [VC-JOSE-COSE] §3.1.1 — full VC JSON-LD becomes JWT payload (no vc wrapper). + # [SD-JWT-VC] §11 — SD-JWT-VC does NOT use W3C VCDM; flat claims. # Harbour claim_mapping.py bridges W3C ↔ SD-JWT-VC formats. HarbourCredential: @@ -317,31 +334,41 @@ classes: - validFrom - validUntil - evidence - attributes: - # Spec: VCDM2 §4.10 — credentialStatus is OPTIONAL, each MUST have type. - # Harbour profile: REQUIRED (stricter than base spec). - # SD-JWT-VC uses "status" claim (MUST NOT be selectively disclosable, - # draft-14 §3.2). CRSet is a Harbour-defined status type; VCDM2 allows - # custom types provided they have id and type. + - credentialStatus + slot_usage: + # Harbour profile constraints on W3C VC envelope terms: + # Stricter than base VCDM2 — issuer and validFrom are REQUIRED. + issuer: + required: true + description: DID of the credential issuer. + validFrom: + required: true + description: > + VCDM2 §4.9 — Harbour profile: REQUIRED (stricter than base spec). + SD-JWT-VC maps to iat (OPTIONAL) or nbf (OPTIONAL). + validUntil: + required: false + evidence: + range: Evidence + required: false + # Harbour profile: credentialStatus REQUIRED with CRSetEntry range. credentialStatus: - slot_uri: cred:credentialStatus range: CRSetEntry - multivalued: true required: true description: Status entries for revocation checking (CRSet). # ========================================== # 4. EVIDENCE TYPES # ========================================== - # Spec: VCDM2 §5.6 — evidence is OPTIONAL (0..*), each object MUST have type. + # [VCDM2] §5.6 — evidence is OPTIONAL (0..*), each object MUST have type. # No specific evidence subtypes are defined by the base spec. Evidence: abstract: true class_uri: harbour:Evidence # Harbour-specific evidence type for authorization proof during issuance. - # Spec: VCDM2 §5.6 — evidence can contain any claims (extensible). - # Spec: VC-JOSE §6.1 — embedded VP is application/vp+jwt or application/vp+sd-jwt. + # [VCDM2] §5.6 — evidence can contain any claims (extensible). + # [VC-JOSE-COSE] §6.1 — embedded VP is application/vp+jwt or application/vp+sd-jwt. CredentialEvidence: is_a: Evidence description: > @@ -364,12 +391,12 @@ classes: required: true # Harbour-specific evidence type for delegated signing flows. - # Spec: OID4VP §5.1, §8.4 — transaction_data in authorization request. - # Spec: OID4VP §B.3.3 — KB-JWT carries transaction_data_hashes + _alg. - # Spec: CSC-DM v1.0.0 — signatureRequest triggers OID4VP flow. - # Spec: Harbour Delegation Spec §3 — challenge = " HARBOUR_DELEGATE ". + # [OID4VP] §5.1, §8.4 — transaction_data in authorization request. + # [OID4VP] §B.3.3 — KB-JWT carries transaction_data_hashes + _alg. + # [CSC-DM] v1.0.0 — signatureRequest triggers OID4VP flow. + # Harbour Delegation Spec §3 — challenge = " HARBOUR_DELEGATE ". # transaction_data contains type, credential_ids, nonce, iat, txn. - # Spec: OID4VP §B.1.3.2.5 — Data Integrity proof.challenge = OID4VP nonce; + # [OID4VP] §B.1.3.2.5 — Data Integrity proof.challenge = OID4VP nonce; # proof.domain = OID4VP client_id. KB-JWT equivalent: nonce = nonce, aud = client_id. DelegatedSignatureEvidence: is_a: Evidence @@ -391,9 +418,9 @@ classes: transaction_data: required: true - # Spec: VCDM2 §4.10 — credentialStatus entries MUST have id and type. + # [VCDM2] §4.10 — credentialStatus entries MUST have id and type. # CRSet is a Harbour-defined status type (not BitstringStatusListEntry). - # SD-JWT-VC uses "status" with status_list sub-object (idx + uri). + # [SD-JWT-VC] §3.2 — "status" claim uses status_list sub-object (idx + uri). CRSetEntry: class_uri: harbour:CRSetEntry slots: @@ -402,10 +429,10 @@ classes: # ========================================== # 6. HELPERS # ========================================== - # Spec: DID-Core §5.3.1 — verificationMethod MUST have id, type, controller, + # [DID-CORE] §5.3.1 — verificationMethod MUST have id, type, controller, # and key material (publicKeyJwk or publicKeyMultibase). Harbour models a # subset; blockchainAccountId is a Harbour extension for on-chain binding. - # Spec: VC-JOSE §4.2 — verification method type MUST be JsonWebKey; key + # [VC-JOSE-COSE] §4.2 — verification method type MUST be JsonWebKey; key # material MUST be in publicKeyJwk property. VerificationMethod: class_uri: https://www.w3.org/ns/did#VerificationMethod diff --git a/linkml/harbour-gx-credential.yaml b/linkml/harbour-gx-credential.yaml index 237c5d0..8234fda 100644 --- a/linkml/harbour-gx-credential.yaml +++ b/linkml/harbour-gx-credential.yaml @@ -7,6 +7,34 @@ description: > nested gx blank nodes carry only gx properties, keeping gx closed SHACL shapes intact. +# ============================================================================ +# SPECIFICATION REFERENCES +# ============================================================================ +# [VCDM2] W3C Verifiable Credentials Data Model v2.0 +# https://www.w3.org/TR/vc-data-model-2.0/ +# [SD-JWT-VC] SD-JWT-based Verifiable Credentials (draft-15) +# https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/ +# [GX-AD] Gaia-X Architecture Document 25.11 +# https://docs.gaia-x.eu/technical-committee/architecture-document/25.11/ +# [GX-SHACL] Gaia-X SHACL Shapes (local: OMB artifacts/gx/gx.shacl.ttl) +# Namespace: https://w3id.org/gaia-x/development# +# [SCHEMA-ORG] schema.org vocabulary +# https://schema.org/ +# +# DESIGN DECISIONS +# ============================================================================ +# Composition over extension: Gaia-X Trust Framework defines closed SHACL +# shapes (sh:closed true) on gx: types. Adding ANY property to a gx: node +# violates the closed shape. Therefore Harbour uses composition — the +# harbour outer node owns harbour-specific properties, and a nested gx +# blank node carries only gx-valid properties. +# See: docs/specs/references/gx-architecture-document-25.11.md +# +# gxParticipant range is Any: The gx blank node content is validated by +# Gaia-X's own SHACL shapes (gx.shacl.ttl), not harbour's. Harbour SHACL +# is generated with exclude_imports=True to keep shapes separate. +# ============================================================================ + prefixes: linkml: https://w3id.org/linkml/ harbour: https://w3id.org/reachhaven/harbour/credentials/v1/ @@ -24,14 +52,13 @@ imports: # ========================================== # Composition Slots # ========================================== -# These slots link harbour outer nodes to Gaia-X inner blank nodes. -# Design: Gaia-X Trust Framework defines closed SHACL shapes on gx: types. -# Harbour cannot add properties to gx nodes without violating sh:closed. +# [GX-AD] — Gaia-X Trust Framework 25.11 defines closed SHACL shapes on +# gx:LegalPerson, gx:Participant. These shapes require specific properties +# (registrationNumber, headquartersAddress, legalAddress). +# [GX-SHACL] — gx:LegalPersonShape has sh:closed true (see gx.shacl.ttl). +# Adding properties to gx nodes violates the closed shape constraint. # Solution: composition — harbour outer node owns harbour properties; # nested gx blank node carries only gx properties. -# Spec: Gaia-X Trust Framework 25.11 (GX-ICAM-25.11, GX-TF-ARCH) — -# gx:LegalPerson, gx:Participant; Gaia-X shapes requiring specific -# properties (registrationNumber, headquartersAddress, legalAddress). slots: gxParticipant: @@ -51,7 +78,7 @@ classes: # Defined in the domain layer because the credentialSubject types # (LegalPerson, NaturalPerson) carry domain-specific composition # slots (gxParticipant) that must be present in the SHACL closed shapes. - # Spec: VCDM2 §4 — each credential MUST have @context, type, issuer, + # [VCDM2] §4 — each credential MUST have @context, type, issuer, # credentialSubject. Harbour profile additionally requires validFrom # and credentialStatus (inherited from HarbourCredential). @@ -64,9 +91,8 @@ classes: gx:LegalPerson blank node with compliance data. class_uri: harbour:LegalPersonCredential annotations: - # Spec: SD-JWT-VC-15 §3.2.2.1 — vct MUST be a case-sensitive - # StringOrURI identifying the credential type. - # Spec: SD-JWT-VC-TYPES — vct URI SHOULD be stable and dereferenceable. + # [SD-JWT-VC] §3.2.2.1 — vct MUST be a case-sensitive StringOrURI + # identifying the credential type. vct: "https://w3id.org/reachhaven/harbour/credentials/v1/LegalPersonCredential" slot_usage: validFrom: @@ -83,6 +109,7 @@ classes: is needed, gxParticipant carries the gx:Participant blank node. class_uri: harbour:NaturalPersonCredential annotations: + # [SD-JWT-VC] §3.2.2.1 — vct MUST be a case-sensitive StringOrURI. vct: "https://w3id.org/reachhaven/harbour/credentials/v1/NaturalPersonCredential" slot_usage: validFrom: @@ -94,12 +121,12 @@ classes: # 2. PARTICIPANT TYPES # ========================================== # Harbour wraps Gaia-X participant types via composition. - # Gaia-X data lives in nested blank nodes (gxParticipant / - # gxParticipant) to keep gx closed shapes intact. - # Spec: Gaia-X Trust Framework 25.11 (GX-TF-ARCH) — - # gx:LegalPerson requires gx:registrationNumber (object), - # gx:headquartersAddress, gx:legalAddress. - # gx:Participant is the base type. + # Gaia-X data lives in nested blank nodes (gxParticipant) to keep + # gx closed shapes intact. + # [GX-SHACL] — gx:LegalPersonShape (sh:closed true) requires: + # gx:registrationNumber (≥1), gx:legalAddress (=1), + # gx:headquartersAddress (=1). Optional: schema:name (≤1), + # schema:description (≤1), gx:parentOrganizationOf, gx:subOrganisationOf. LegalPerson: description: > @@ -130,17 +157,19 @@ classes: name: required: false attributes: - # Spec: schema.org Person vocabulary — givenName, familyName, email. + # [SCHEMA-ORG] — https://schema.org/givenName givenName: slot_uri: schema:givenName range: string + # [SCHEMA-ORG] — https://schema.org/familyName familyName: slot_uri: schema:familyName range: string + # [SCHEMA-ORG] — https://schema.org/email email: slot_uri: schema:email range: string - # Spec: schema.org — memberOf relates Person to Organization. + # [SCHEMA-ORG] — https://schema.org/memberOf memberOf: description: Organization (LegalPerson) the natural person belongs to. slot_uri: schema:memberOf diff --git a/linkml/w3c-vc.yaml b/linkml/w3c-vc.yaml new file mode 100644 index 0000000..813b47c --- /dev/null +++ b/linkml/w3c-vc.yaml @@ -0,0 +1,96 @@ +id: https://www.w3.org/2018/credentials +name: w3c-vc +description: > + LinkML projection of W3C Verifiable Credentials Data Model v2.0 vocabulary + terms. Defines the VC envelope properties (issuer, validFrom, validUntil, + evidence, credentialStatus) so they can be imported and constrained by + downstream schemas via slot_usage without redefining them. + The JSON-LD context for these terms is provided by the W3C VC v2 context + (https://www.w3.org/ns/credentials/v2), NOT by this schema. + +# ============================================================================ +# SPECIFICATION REFERENCES +# ============================================================================ +# [VCDM2] W3C Verifiable Credentials Data Model v2.0 +# https://www.w3.org/TR/vc-data-model-2.0/ +# [VC-CTX] W3C VC v2 JSON-LD Context +# https://www.w3.org/ns/credentials/v2 +# +# DESIGN DECISION: Why this LinkML shim file exists +# ============================================================================ +# LinkML cannot import external OWL ontologies or JSON-LD contexts directly. +# The imports: directive only accepts other LinkML schemas or linkml:types. +# This was confirmed by LinkML issue #1950 (External Namespace Support) which +# was CLOSED — wrapping external terms in a thin LinkML schema is the +# officially recommended pattern. +# https://github.com/linkml/linkml/issues/1950 +# +# This schema is intentionally minimal: +# - All constraints are LOOSE (matching the base W3C spec optionality) +# - Downstream schemas (e.g. harbour-core-credential.yaml) refine constraints +# via slot_usage to create stricter profiles +# - default_prefix: cred — schema ID matches the external vocabulary namespace +# ============================================================================ + +prefixes: + linkml: https://w3id.org/linkml/ + cred: https://www.w3.org/2018/credentials# + cs: https://www.w3.org/ns/credentials/status# + xsd: http://www.w3.org/2001/XMLSchema# + +default_prefix: cred +default_range: string + +imports: + - linkml:types + +slots: + # [VCDM2] §4.7 — A verifiable credential MUST have an issuer property. + # The value MUST be either a URL or an object containing an id property + # whose value is a URL. Modeled as range: string here because LinkML has + # no native IRI type; the SHACL generator is patched downstream to emit + # sh:nodeKind sh:IRIOrLiteral (see HarbourShaclGenerator). + issuer: + slot_uri: cred:issuer + range: string + description: > + [VCDM2] §4.7 — issuer MUST exist; value MUST be a URL or object with id. + + # [VCDM2] §4.9 — If present, the value of validFrom MUST be an + # xsd:dateTimeStamp string value representing the date and time the + # credential becomes valid. + validFrom: + slot_uri: cred:validFrom + range: datetime + description: > + [VCDM2] §4.9 — validFrom is OPTIONAL, xsd:dateTime with mandatory + timezone offset. + + # [VCDM2] §4.9 — If present, the value of validUntil MUST be an + # xsd:dateTimeStamp string value representing the date and time the + # credential ceases to be valid. + validUntil: + slot_uri: cred:validUntil + range: datetime + description: > + [VCDM2] §4.9 — validUntil is OPTIONAL, xsd:dateTime. + + # [VCDM2] §5.6 — The evidence property provides information about the + # process and/or evidence the issuer used when evaluating the claims + # made in the credential. Each evidence object MUST specify its type. + evidence: + slot_uri: cred:evidence + multivalued: true + description: > + [VCDM2] §5.6 — evidence is OPTIONAL (0..*), each object MUST have type. + + # [VCDM2] §4.10 — The credentialStatus property is OPTIONAL and is used + # to discover information about the current status of a verifiable + # credential (e.g. whether it is suspended or revoked). Each status + # entry MUST specify its id and type. + credentialStatus: + slot_uri: cred:credentialStatus + multivalued: true + description: > + [VCDM2] §4.10 — credentialStatus is OPTIONAL (0..*), each MUST have + id and type. diff --git a/src/python/harbour/generate_artifacts.py b/src/python/harbour/generate_artifacts.py index 79255d6..49212e6 100644 --- a/src/python/harbour/generate_artifacts.py +++ b/src/python/harbour/generate_artifacts.py @@ -7,14 +7,19 @@ context defines ``issuer`` with ``@type: @id``, so the RDF value is an IRI. The generator patches the property shape to ``sh:nodeKind sh:IRIOrLiteral`` (accepting both IRIs from JSON-LD and literal strings from plain JSON). + +The custom HarbourContextGenerator excludes terms imported from external +vocabularies (e.g. W3C VC v2) so the generated JSON-LD context does not +redefine ``@protected`` terms already provided by the W3C VC v2 context. """ import json from pathlib import Path -from linkml.generators.jsonldcontextgen import ContextGenerator +from linkml.generators.jsonldcontextgen import ContextGenerator as _BaseContextGenerator from linkml.generators.owlgen import OwlSchemaGenerator from linkml.generators.shaclgen import ShaclGenerator as _BaseShaclGenerator +from linkml_runtime.linkml_model.meta import SlotDefinition from rdflib import Namespace REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent @@ -56,6 +61,25 @@ def as_graph(self): return g +class HarbourContextGenerator(_BaseContextGenerator): + """Context generator that excludes imported vocabulary terms. + + W3C VC v2 envelope terms (issuer, validFrom, validUntil, evidence, + credentialStatus) are defined in ``w3c-vc.yaml`` and imported into + harbour schemas. With ``mergeimports=False`` these slots are marked + with ``imported_from``. This generator skips them so the harbour + JSON-LD context does not redefine ``@protected`` terms already + provided by ``https://www.w3.org/ns/credentials/v2``. + """ + + def visit_slot(self, aliased_slot_name: str, slot: SlotDefinition) -> None: + if getattr(slot, "imported_from", None) and not str( + slot.imported_from + ).startswith("linkml"): + return + super().visit_slot(aliased_slot_name, slot) + + def main() -> None: for domain in DOMAINS: schema = str(LINKML_DIR / f"{domain}.yaml") @@ -64,7 +88,7 @@ def main() -> None: print(f" Processing {domain}...") - owl_gen = OwlSchemaGenerator(schema) + owl_gen = OwlSchemaGenerator(schema, mergeimports=False) (out_dir / f"{domain}.owl.ttl").write_text( owl_gen.serialize(), encoding="utf-8" ) @@ -74,7 +98,7 @@ def main() -> None: shacl_gen.serialize(), encoding="utf-8" ) - ctx_gen = ContextGenerator(schema) + ctx_gen = HarbourContextGenerator(schema, mergeimports=False) ctx_text = ctx_gen.serialize() # Ensure "type": "@type" is present in the generated context. From 8d71132a1f3706ebda8132fbe88ff515a686887b Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Tue, 10 Mar 2026 16:09:04 +0100 Subject: [PATCH 19/78] docs: add credential data model documentation and expand architecture - Add docs/schema/credential-model.md with Mermaid class diagrams: credential type hierarchy, evidence inheritance, subject types, Gaia-X composition pattern, revocation infrastructure, DID model, artifact generation pipeline, and complete class map - Expand docs/architecture.md from stub to full overview with component diagram, signing flow sequence diagram, and ADR-005 reference - Add Schema section and ADR-005 to mkdocs.yml navigation - Fix did-webs-spec.md ::: markers clashing with mkdocstrings - Add site/ to .gitignore Signed-off-by: Carlo van Driesten --- .gitignore | 1 + docs/architecture.md | 104 +++++-- docs/schema/credential-model.md | 357 +++++++++++++++++++++++++ docs/specs/references/did-webs-spec.md | 100 +++---- mkdocs.yml | 3 + 5 files changed, 497 insertions(+), 68 deletions(-) create mode 100644 docs/schema/credential-model.md diff --git a/.gitignore b/.gitignore index 2a243a7..22b7491 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ node_modules/ # Agent workspace (untracked) /.playground/ +site/ diff --git a/docs/architecture.md b/docs/architecture.md index 87a5c3c..3c96bc2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,62 @@ -# Harbour Credentials — Design Documentation +# Architecture Overview -## Package Structure (Current) +Harbour Credentials is a dual-runtime cryptographic library for signing +and verifying W3C Verifiable Credentials. It spans three layers: + +1. **Schema layer** — LinkML definitions that produce OWL, SHACL, and JSON-LD + context artifacts +2. **Crypto layer** — Python and TypeScript modules for key management, + signing (VC-JOSE-COSE, SD-JWT-VC), and verification +3. **Infrastructure layer** — DID documents, revocation (CRSet), and + Gaia-X compliance composition + +## Component Diagram + +```mermaid +flowchart TB + subgraph schema["Schema Layer"] + LM["LinkML Schemas
(w3c-vc, core, gx)"] + OWL["OWL Ontology"] + SHACL["SHACL Shapes"] + CTX["JSON-LD Context"] + end + + subgraph crypto["Crypto Layer"] + PY["Python
harbour.*"] + TS["TypeScript
harbour"] + end + + subgraph infra["Infrastructure"] + DID["DID Documents
(did:ethr, did:key)"] + CRSET["CRSet Revocation"] + GX["Gaia-X Compliance
(gxParticipant)"] + end + + subgraph output["Outputs"] + JOSE["VC-JOSE-COSE
(Gaia-X JWT)"] + SDJWT["SD-JWT-VC
(EUDI wallet)"] + end + + LM --> OWL & SHACL & CTX + CTX --> PY & TS + PY --> JOSE & SDJWT + TS --> JOSE & SDJWT + DID --> PY & TS + CRSET --> PY + GX --> SHACL + + style schema fill:#fff3e0,stroke:#e65100 + style crypto fill:#e3f2fd,stroke:#1565c0 + style infra fill:#f3e5f5,stroke:#6a1b9a + style output fill:#e8f5e9,stroke:#2e7d32 +``` + +## Data Model + +For the full credential type hierarchy, evidence model, Gaia-X composition +pattern, and class map, see [Credential Data Model](schema/credential-model.md). + +## Package Structure ```text harbour-credentials/ @@ -15,10 +71,35 @@ harbour-credentials/ │ ├── interop/ # Cross-runtime interoperability tests │ ├── python/ # Python tests (harbour + credentials) │ └── typescript/harbour/ # TypeScript tests -├── linkml/ # LinkML schemas (harbour-core-credential.yaml, harbour-gx-credential.yaml) +├── linkml/ # LinkML schemas └── artifacts/ # Generated OWL/SHACL/context (per domain) ``` +## Signing Flow + +```mermaid +sequenceDiagram + participant I as Issuer + participant H as Harbour Library + participant W as Wallet + + I->>H: Credential JSON + Private Key + H->>H: Resolve JSON-LD Context + H->>H: Sign (ES256 / P-256) + + alt VC-JOSE-COSE + H-->>I: JWT (compact serialisation) + else SD-JWT-VC + H->>H: Select disclosable claims + H-->>I: SD-JWT (issuer + disclosures + KB-JWT) + end + + I->>W: Deliver signed credential + W->>H: Verify signature + resolve DID + H->>H: Check revocation (CRSet) + H-->>W: Verification result +``` + ## Architecture Decision Records | # | Decision | Status | @@ -27,20 +108,7 @@ harbour-credentials/ | [002](decisions/002-dual-runtime-architecture.md) | Dual Python/JavaScript runtime | Accepted | | [003](decisions/003-canonicalization.md) | No canonicalization required | Accepted | | [004](decisions/004-key-management.md) | ES256 (P-256) primary + X.509 + DID | Accepted | - -## Implementation Status - -| Aspect | Status | -|--------|--------| -| Proof format | SD-JWT-VC + VC-JOSE-COSE | -| Algorithm | ES256 (P-256) primary, EdDSA (Ed25519) supported | -| Key resolution | X.509 (x5c) + did:ethr + did:key | -| Selective disclosure | Native (SD-JWT-VC) | -| Canonicalization | None needed (JWT/SD-JWT) | -| Runtimes | Python + TypeScript | -| EUDI compatible | Yes | -| Gaia-X compatible | Yes | -| OIDC4VP ready | Yes | +| [005](decisions/005-did-ethr-migration.md) | did:ethr migration to Base L2 | Accepted | ## Format Relationship @@ -55,4 +123,4 @@ LinkML Schema → JSON-LD Context + SHACL (schema validation) The schema validation layer (SHACL/JSON-LD) validates the attribute design. The signing layer (JWT/SD-JWT) secures the credential for transport. -Both layers use the same attribute definitions, different serializations. +Both layers use the same attribute definitions, different serialisations. diff --git a/docs/schema/credential-model.md b/docs/schema/credential-model.md new file mode 100644 index 0000000..29f5132 --- /dev/null +++ b/docs/schema/credential-model.md @@ -0,0 +1,357 @@ +# Credential Data Model + +This page documents the LinkML schema inheritance hierarchy, composition +patterns, and trust chain architecture used by Harbour Credentials. + +## Schema File Structure + +```text +linkml/ +├── w3c-vc.yaml # W3C VC Data Model v2.0 envelope +├── harbour-core-credential.yaml # Abstract base, evidence, revocation, DID +└── harbour-gx-credential.yaml # Gaia-X domain layer (participants) +``` + +Each file builds on the previous one through LinkML `imports`. + +## Import Chain + +```mermaid +graph LR + W["w3c-vc.yaml
VC envelope"] + H["harbour-core-credential.yaml
Abstract base + infra"] + G["harbour-gx-credential.yaml
Gaia-X domain"] + + W --> H + H --> G + + style W fill:#e3f2fd,stroke:#1565c0 + style H fill:#f3e5f5,stroke:#6a1b9a + style G fill:#e8f5e9,stroke:#2e7d32 +``` + +Downstream consumers (e.g. SimpulseID) import `harbour-core-credential` +via an import map and define their own credential types on top. + +--- + +## Credential Type Hierarchy + +All credential types inherit from `HarbourCredential`, which strengthens +the optional W3C VC v2.0 envelope fields into a harbour-specific profile. + +```mermaid +classDiagram + class W3C_VC_Envelope { + <> + +uri issuer + +datetime validFrom + +datetime validUntil + +Evidence[] evidence + +CredentialStatus[] credentialStatus + } + + class HarbourCredential { + <> + issuer : uri ⟨required⟩ + validFrom : datetime ⟨required⟩ + validUntil : datetime + evidence : Evidence[] ⟨required⟩ + credentialStatus : CRSetEntry[] ⟨required⟩ + } + + class LegalPersonCredential { + class_uri = harbour:LegalPersonCredential + vct = "…/LegalPersonCredential" + validFrom : required + evidence : required + } + + class NaturalPersonCredential { + class_uri = harbour:NaturalPersonCredential + vct = "…/NaturalPersonCredential" + validFrom : required + evidence : required + } + + W3C_VC_Envelope <|-- HarbourCredential : imports + strengthens + HarbourCredential <|-- LegalPersonCredential + HarbourCredential <|-- NaturalPersonCredential +``` + +### What `HarbourCredential` Strengthens + +The W3C VC Data Model v2.0 defines most envelope fields as optional. +`HarbourCredential` tightens these for the harbour profile: + +| Field | W3C VC v2.0 | HarbourCredential | +|-------|-------------|-------------------| +| `issuer` | optional | **required** | +| `validFrom` | optional | **required** | +| `validUntil` | optional | optional | +| `evidence` | optional | **required** | +| `credentialStatus` | optional | **required** (range: `CRSetEntry`) | + +!!! note "Downstream overrides" + Consumers like SimpulseID may loosen these constraints via `slot_usage`. + For example, SimpulseID makes `evidence` and `credentialStatus` optional + for its credential types. + +--- + +## Evidence Hierarchy + +Evidence documents how a credential's claims were verified. Harbour defines +an abstract base with two concrete types: + +```mermaid +classDiagram + class Evidence { + <> + type : string ⟨required⟩ + verifier : uri ⟨required⟩ + verificationMethod : uri ⟨required⟩ + } + + class CredentialEvidence { + evidenceDocument : uri + subjectPresence : string + documentPresence : string + } + + class DelegatedSignatureEvidence { + challenge : string ⟨required⟩ + domain : string ⟨required⟩ + } + + Evidence <|-- CredentialEvidence + Evidence <|-- DelegatedSignatureEvidence +``` + +**`CredentialEvidence`** — attests that a human verifier checked documents +(identity papers, registration certificates) before issuance. + +**`DelegatedSignatureEvidence`** — attests that the subject authorized a +signing service to act on their behalf via an OID4VP challenge-response +flow. See [Delegated Signing](../guide/delegated-signing.md). + +--- + +## Credential Subject Types + +Subject types define what a credential asserts about a person or +organisation. These are **not** inherited from `HarbourCredential` — they +are standalone classes used as the `credentialSubject` value. + +```mermaid +classDiagram + class LegalPerson { + class_uri = harbour:LegalPerson + name : string + gxParticipant : Any + } + + class NaturalPerson { + class_uri = harbour:NaturalPerson + name : string + gxParticipant : Any + givenName : string + familyName : string + email : string + memberOf : uri + } +``` + +### Credential ↔ Subject Pairing + +| Credential Type | Subject Type | Use Case | +|----------------|-------------|----------| +| `LegalPersonCredential` | `LegalPerson` | Organisation identity | +| `NaturalPersonCredential` | `NaturalPerson` | Individual identity | + +--- + +## Gaia-X Composition Pattern + +Gaia-X Trust Framework defines **closed SHACL shapes** (`sh:closed true`) +on `gx:LegalPerson` and `gx:Participant`. Adding any non-gx property to +a `gx:` node violates the closed shape constraint. + +Harbour solves this with **composition** — the outer harbour node owns +harbour-specific properties, and a nested blank node carries only +gx-valid properties: + +```mermaid +graph TD + subgraph "harbour:LegalPerson (outer node)" + A["harbour:name = 'ACME Corp'"] + B["harbour:gxParticipant"] + end + + subgraph "_:b0 (gx blank node)" + C["@type = gx:LegalPerson"] + D["gx:registrationNumber = …"] + E["gx:legalAddress = …"] + F["gx:headquartersAddress = …"] + end + + B --> C + + style A fill:#f3e5f5,stroke:#6a1b9a + style B fill:#f3e5f5,stroke:#6a1b9a + style C fill:#e8f5e9,stroke:#2e7d32 + style D fill:#e8f5e9,stroke:#2e7d32 + style E fill:#e8f5e9,stroke:#2e7d32 + style F fill:#e8f5e9,stroke:#2e7d32 +``` + +### Why Not Extend gx:LegalPerson Directly? + +Adding harbour properties to a `gx:` node violates `sh:closed`: + +```turtle +# ❌ Wrong — SHACL violation +harbour:MyOrg a gx:LegalPerson ; + gx:registrationNumber … ; + harbour:extraField "value" . +``` + +Composition keeps gx shapes intact: + +```turtle +# ✅ Correct — separate nodes +harbour:MyOrg a harbour:LegalPerson ; + harbour:name "ACME" ; + harbour:gxParticipant [ + a gx:LegalPerson ; + gx:registrationNumber … + ] . +``` + +The `gxParticipant` slot has `range: Any` because the nested content is +validated by Gaia-X's own SHACL shapes (`gx.shacl.ttl`), not harbour's. +Harbour generates its SHACL with `exclude_imports=True` to keep shape +sets separate. + +--- + +## Revocation Infrastructure + +Harbour uses a **Credential Revocation Set (CRSet)** mechanism for +status management: + +```mermaid +classDiagram + class CRSetEntry { + class_uri = harbour:CRSetEntry + type : string ⟨required⟩ + statusPurpose : string ⟨required⟩ + statusListIndex : integer ⟨required⟩ + statusListCredential : uri ⟨required⟩ + } +``` + +Each credential carries a `credentialStatus` array of `CRSetEntry` +objects pointing to an on-chain or hosted status list. + +--- + +## DID Document Model + +Harbour defines a DID Document structure for key resolution and service +discovery: + +```mermaid +classDiagram + class DIDDocument { + verificationMethod : VerificationMethod[] + service : Service[] + } + + class VerificationMethod { + type : string ⟨required⟩ + controller : uri ⟨required⟩ + publicKeyJwk : string + } + + class Service { + <> + } + + class TrustAnchorService { + type : string ⟨required⟩ + serviceEndpoint : uri ⟨required⟩ + } + + class LinkedCredentialService { + type : string ⟨required⟩ + serviceEndpoint : uri ⟨required⟩ + } + + class CRSetRevocationRegistryService { + type : string ⟨required⟩ + serviceEndpoint : uri ⟨required⟩ + } + + DIDDocument --> VerificationMethod + DIDDocument --> Service + Service <|-- TrustAnchorService + Service <|-- LinkedCredentialService + Service <|-- CRSetRevocationRegistryService +``` + +--- + +## Artifact Generation Pipeline + +LinkML schemas produce three types of artifacts: + +```mermaid +flowchart LR + S["LinkML Schema
(.yaml)"] --> OWL["OWL Ontology
(.owl.ttl)"] + S --> SHACL["SHACL Shapes
(.shacl.ttl)"] + S --> CTX["JSON-LD Context
(.context.jsonld)"] + + OWL --> V["SHACL Validation"] + SHACL --> V + CTX --> V + V --> E["Example Credentials
(.json)"] + + style S fill:#fff3e0,stroke:#e65100 + style OWL fill:#e3f2fd,stroke:#1565c0 + style SHACL fill:#fce4ec,stroke:#c62828 + style CTX fill:#e8f5e9,stroke:#2e7d32 + style V fill:#f3e5f5,stroke:#6a1b9a + style E fill:#fffde7,stroke:#f57f17 +``` + +| Artifact | Purpose | Generated By | +|----------|---------|-------------| +| **OWL** (`.owl.ttl`) | Class hierarchy and property definitions | `gen-owl` | +| **SHACL** (`.shacl.ttl`) | Validation constraints (required, ranges, cardinality) | `HarbourShaclGenerator` | +| **JSON-LD Context** (`.context.jsonld`) | Term-to-IRI mappings for JSON-LD serialisation | `DomainContextGenerator` | + +Run `make generate` to regenerate all artifacts from schemas. + +--- + +## Complete Class Map + +For quick reference, every class defined across all three schema files: + +| Class | Schema File | Abstract | Parent | Domain | +|-------|-------------|----------|--------|--------| +| `HarbourCredential` | core | ✅ | *(W3C VC envelope)* | Core | +| `Evidence` | core | ✅ | — | Core | +| `CredentialEvidence` | core | — | `Evidence` | Core | +| `DelegatedSignatureEvidence` | core | — | `Evidence` | Core | +| `CRSetEntry` | core | — | — | Core | +| `DIDDocument` | core | — | — | Core | +| `VerificationMethod` | core | — | — | Core | +| `TrustAnchorService` | core | — | *(Service union)* | Core | +| `LinkedCredentialService` | core | — | *(Service union)* | Core | +| `CRSetRevocationRegistryService` | core | — | *(Service union)* | Core | +| `LegalPersonCredential` | gx | — | `HarbourCredential` | Gaia-X | +| `NaturalPersonCredential` | gx | — | `HarbourCredential` | Gaia-X | +| `LegalPerson` | gx | — | — | Gaia-X | +| `NaturalPerson` | gx | — | — | Gaia-X | diff --git a/docs/specs/references/did-webs-spec.md b/docs/specs/references/did-webs-spec.md index 4caceab..e7c42fb 100644 --- a/docs/specs/references/did-webs-spec.md +++ b/docs/specs/references/did-webs-spec.md @@ -44,7 +44,7 @@ either a drawback or a benefit (or both). ## Introduction -::: informative Introduction +::: informative Introduction DID methods answer many questions. Two noteworthy ones are: @@ -74,7 +74,7 @@ did:webs:example.com%3A3000:users:alice:EKYGGh-FtAphGmSZbsuBs_t4qpsjYJ2ZqvMKluq9 └───────────────────────────────────────── Method ``` -::: +::: --- @@ -92,9 +92,9 @@ This section is normative. 1. The remainder of the DID, after the prefix, MUST be the case-sensitive [[ref: method-specific identifier]] ([[ref: MSI]]) described [below](#method-specific-identifier). -::: informative Note on pronunciation +::: informative Note on pronunciation Note: when pronounced aloud, “webs” should become two syllables: the word “web” and the letter “s” (which stands for “secure”). Separating the final letter this way emphasizes that the method offers a security upgrade surpassing the one HTTPS gives to HTTP. -::: +::: ### Method-Specific Identifier @@ -144,9 +144,9 @@ said-512 = two-char-code 86base64urlsafe 1. Directories and subdirectories MAY optionally be included, delimited by colons rather than slashes. 1. The KERI AID is a unique identifier and MUST be derived from the [[ref: inception event]] of a KERI identifier. -::: informative did:web compatibility +::: informative did:web compatibility To be compatible with `did:web`, the AID is "just a path", the final (and perhaps only) path element. The presence of the required AID as a path element means that a `did:webs` always has a path,and so the "no path" version of a `did:web` that implicitly uses the `.well-known` location is not supported by `did:webs`. Any `did:webs` can be expressed as a `did:web` but the inverse is not true--a `did:webs` must include an AID. -::: +::: ### Target System(s) @@ -165,7 +165,7 @@ To be compatible with `did:web`, the AID is "just a path", the final (and perhap 3. The KERI event stream MUST be [[ref: CESR]]-formatted (media type of application/cesr) and the KERI events must be verifiable using the KERI rules. 1. The `did:web` version of the DIDs MUST be the same (minus the `s`) and point to the same `did.json` file, but have no knowledge of the `keri.cesr` file. -::: informative Target system and KERI verifiability +::: informative Target system and KERI verifiability For more information, see the following sections in the implementors guide: * [the set of KERI features needed](#the-set-of-keri-features-needed) to support `did:webs` @@ -185,7 +185,7 @@ The following are some example `did:webs` DIDs and their corresponding DID docum * The DID document URL would look like: `https://example.com:3000/user/alice/12124313423525/did.json` * [[ref: KERI event stream]] URL would look like: `https://example.com:3000/user/alice/12124313423525/keri.cesr` -::: +::: ### AID controlled identifiers @@ -195,9 +195,9 @@ The following are some example `did:webs` DIDs and their corresponding DID docum 1. If the KERI event streams diverge from one other (e.g., one is not a subset of the other), both the KERI event streams and the DIDs MUST be considered invalid. 1. The verification of the KERI event stream SHOULD provide mechanisms for detecting the forking of the KERI event stream by using mechanisms such as KERI witnesses and watchers. -::: informative AID and KERI event stream binding +::: informative AID and KERI event stream binding Since an AID is a unique cryptographic identifier that is inseparably bound to the [[ref: KERI event stream]] it is associated with any AIDs and any `did:webs` DIDs that have the same AID component. It can be verifiably proven that they have the same controller(s). -::: +::: ### Handling Web Redirection @@ -215,9 +215,9 @@ Since an AID is a unique cryptographic identifier that is inseparably bound to t 1. If possible, the controller of the DID MAY use web redirects to allow resolution of the old location of the DID to the new location. 1. If the previously published location of a `did:webs` DID is not redirected, an entity trying to resolve the DID MAY be able to find the data for the DID somewhere else using just the AID. -::: informative Stable identifiers +::: informative Stable identifiers The implementors guide contains more information about `did:webs` [[ref: stable identifiers on an unstable web]]. -::: +::: ### DID Method Operations @@ -231,11 +231,11 @@ The implementors guide contains more information about `did:webs` [[ref: stable 1. For compatibility reasons, transformation of the derived `did:webs` DID document to the corresponding `did:web` DID document MUST be according to section [Transformation to did:web DID Document](#transformation-to-didweb-did-document). 1. MUST make the did:web DID document resource (`did.json`) and the [[ref: KERI event stream]] resource (`keri.cesr`) available at the selected location. See section [Target System(s)](#target-systems) for further details about the locations of these resources. -::: informative Publishing and hosting +::: informative Publishing and hosting Of course, the web server that serves the resources when asked might be a simple file server (as implied above) or an active component that generates them dynamically. Further, the publisher of the resources placed on the web can use capabilities like [CDNs] to distribute the resources. How the resources are posted at the required location is not defined by this spec; complying implementations need not support any HTTP methods other than GET. An active component might be used by the controller of the DID to automate the process of publishing and updating the DID document and [[ref: KERI event stream]] resources. -::: +::: #### Read (Resolve) @@ -247,9 +247,9 @@ An active component might be used by the controller of the DID to automate the p 1. MUST verify that the derived `did:webs` DID document equals the transformed DID document. 1. KERI-aware applications MAY use the KERI event stream to make use of additional capabilities enabled by the use of KERI. -::: informative Scope of KERI capabilities +::: informative Scope of KERI capabilities Capabilities beyond the verification of the DID document, the KERI event stream, and delegated identifiers are outside the scope of this specification. -::: +::: #### Update @@ -273,7 +273,7 @@ the [[ref: KERI event stream]]. ## KERI Fundamentals -::: informative KERI Fundamentals +::: informative KERI Fundamentals [[ref: Key Event Receipt Infrastructure)]] is a protocol for managing cryptographic keys, identifiers, and associated verifiable data structures. KERI was first described in an [academic paper](https://arxiv.org/abs/1907.02143), and its [specification](https://github.com/trustoverip/tswg-keri-specification) is currently incubated under [Trust Over IP Foundation](https://trustoverip.org/). The open source community that develops KERI-related technologies can be found at `https://github.com/WebOfTrust/keri`. This section outlines the fundamentals and components of the KERI protocol that are related to the `did:webs` method. @@ -333,7 +333,7 @@ Although _this DID method depends on web technology, KERI itself does not_. It's Despite this rich set of features, KERI imposes only light dependencies on developers. The cryptography it uses is familiar and battle-hardened, exactly what one would find in standard cryptography toolkits. For example, the python implementation (keripy) only depends on the `pysodium`, `blake3`, and `cryptography` python packages. Libraries for KERI exist in javascript, rust, and python. -::: +::: --- @@ -348,7 +348,7 @@ This section is normative. 2. `did:webs` DID documents MUST be pure JSON. They MAY be processed as JSON-LD by prepending an `@context` if consumers of the documents wish. 3. All hashes, cryptographic keys, and signatures MUST be represented as [[ref: CESR]] strings. This is an approach similar to [multibase](https://github.com/multiformats/multibase), making them self-describing and terse. -::: informative Understanding key state and KSN +::: informative Understanding key state and KSN To better understand the cryptographically verifiable data structures used, see the implementors guide description of the [KERI event stream chain of custody](#KERI-event-stream-chain-of-custody). To understand the KERI AID commands resulting in the [[ref: KERI Event Stream]] and the corresponding `did:webs` DID document see the original [[ref: didwebs Reference Implementation]] [getting started guide](https://github.com/GLEIF-IT/did-webs-resolver/blob/main/docs/getting_started.md). In KERI the calculated values that result from processing the [[ref: KERI event stream]] are referred to as the "current key state" and expressed @@ -398,7 +398,7 @@ in the DID document. The following table lists the values from the example KSN In several cases above, the value from the key state is not enough by itself to populate the DID document. The following sections detail the algorithm to follow for each case. -::: +::: ### DID Subject @@ -457,7 +457,7 @@ This section is normative. 1. `did:webs` DIDs MUST be listed in the Designated aliases attestation of the AID. 1. For each [[ref: AID controlled identifier]] DID defined above, an entry in the `alsoKnownAs` array in the DID document MUST be created. -::: informative example alsoKnownAs +::: informative example alsoKnownAs For the example DID `did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBLe` the following `alsoKnownAs` entries could be created: ```json @@ -472,7 +472,7 @@ For the example DID `did:webs:did-webs-service%3a7676:ENro7uf0ePmiK3jdTo2YCdXLqW } ``` -::: +::: ### Verification Methods @@ -490,7 +490,7 @@ Each verification method for a `did:webs` DID is generated from signing keys loc DID Core requires each verification method to have a `controller` property whose value is a valid DID, but does not require that value to equal the `id` of the DID document (e.g., delegation may use a different controller). This specification requires that for `did:webs` the `controller` of every verification method equals the document `id`, since all verification material is derived from the same AID's key state. ::: -::: informative CESR and supported key types +::: informative CESR and supported key types KERI identifiers express public signing keys as Composable Event Streaming Representation (CESR) encoded strings in the `k` field of establishment events and the key state notice. CESR encoding encapsulates all the information needed to determine the cryptographic algorithm used to generate the key pair. At the time of this writing, KERI currently supports public key generation for Ed25519, Secp256k1 and Secp256r1 keys, and the protocol allows for others to be added at any time. @@ -512,7 +512,7 @@ For example, the key `DHr0-I-mMN7h6cLMOTRJkkfPuMd0vgQPrOk4Y3edaHjr` in the DID d ] ``` -::: +::: #### Ed25519 @@ -767,11 +767,11 @@ This section is normative. 1. The `assertionMethod` verification relationship SHALL define that the DID controller can express claims using a combination of multiple keys above the threshold. 1. References to verification methods in the DID document MUST use the relative form of the identifier, e.g., `"authentication": ["#"]`. -::: informative Use of private keys and key agreement +::: informative Use of private keys and key agreement Private keys of a KERI AID can be used to sign a variety of data. This includes but is not limited to logging into a website, challenge-response exchanges, credential issuances, etc. For more information, see the [key agreement](#key-agreement) and [other key commitments](#other-key-commitments) section in the Implementors Guide. -::: +::: ### Service Endpoints @@ -779,11 +779,11 @@ This section is normative. 1. `did:webs` DIDs MUST support service endpoints, including types declared in the DID Specification Registries, such as [DIDCommMessaging](https://www.w3.org/TR/did-extensions-properties/#didcommmessaging). -::: informative Service endpoint mapping and metadata +::: informative Service endpoint mapping and metadata For additional details about the mapping between KERI events and the Service Endpoints in the DID Document, see [Service Endpoint KERI events](#service-endpoint-event-details). It is important to note that DID document service endpoints are different than the KERI service endpoints detailed in [KERI Service Endpoints as DID Document Metadata](#keri-service-endpoints-as-did-document-metadata). -::: +::: #### KERI Service Endpoints as DID Document Metadata @@ -824,9 +824,9 @@ It is important to note that DID document service endpoints are different than t 1. KERI service endpoints roles beyond `witness` SHOULD be defined using Location Scheme and Endpoint Authorization records in KERI. See the [KERI specification](https://trustoverip.github.io/kswg-keri-specification/#oobi-url-iurl) For more information about KERI roles. -::: informative BADA-RUN and service endpoints +::: informative BADA-RUN and service endpoints In KERI, service endpoints are defined by 2 sets of signed data using Best Available Data - Read, Update, Nullify ([[ref: BADA-RUN]]) rules for data processing. The protocol ensures that all data is signed in transport and at rest and versioned to ensure only the latest signed data is available. -::: +::: ### Transformation to `did:web` DID Document @@ -965,7 +965,7 @@ This section defines an inverse transformation algorithm from a `did:web` DID do ### Full Example -::: informative Full Example +::: informative Full Example To walk through a real-world example, please see the GETTING STARTED guide in the [[ref: didwebs Reference Implementation]] as it walks users through many did:webs related tasks (and associated KERI commands) to demonstrate how they work together. @@ -1167,7 +1167,7 @@ Resulting DID document: }... ``` -::: +::: ### Basic KERI event details @@ -1190,13 +1190,13 @@ This section is normative. 1. Rotation events MUST only change the key state to the previously committed to rotation keys. 1. Either the inception event or the last rotation event, if any, is the current key state of the AID and MUST be reflected in the DID Document as specified in [Verification Methods](#verification-methods) and [Verification Relationships](#verification-relationships). -::: informative KERI event references +::: informative KERI event references You can learn more about the inception event in the [[ref: KERI specification]] and you can see an example inception event. To learn about future rotation key commitment, see the sections about [pre-rotation](#pre-rotation) and the KERI specification. You can learn more about rotation events in the KERI specification and you can see an example rotation event. To learn about future rotation key commitment, see the sections about [pre-rotation](#pre-rotation) and the [[ref: KERI specification]]. -::: +::: ### Delegation KERI event details @@ -1207,13 +1207,13 @@ This section focuses on delegation relationships between KERI AIDs. [DID Documen 1. All delegation relationships MUST start with a delegated inception event. 1. Any change to the [[ref: Delegated inception event]] key state or delegated rotation event key state MUST be the result of a delegated rotation event. -::: informative Delegation event summaries +::: informative Delegation event summaries Delegated [[ref: inception event]]: Establishes a delegated identifier. Either the delegator or the delegate can end the delegation commitment. Delegated [[ref: rotation event]]: Updates the delegated identifier commitment. Either the delegator or the delegate can end the delegation commitment. See the [[ref: KERI specification]] for an example of a delegated inception and rotation events. -::: +::: Delegation service endpoints in the DID document are defined in the next section. @@ -1388,9 +1388,9 @@ For example, a `did:webs` DID that is a delegated AID MUST include, in its `serv } ``` -::: informative Delegator endpoint example explanation +::: informative Delegator endpoint example explanation In this example, the `id` field contains the [[ref: SAID]] of the seal in the delegator's [[ref: KEL]] that anchors the delegation commitment, and the `serviceEndpoint` provides the [[ref: OOBI]] URL to retrieve the delegator's key state so that the delegator's KEL may be searched for the delegation seal referred to by the `id` property. -::: +::: ### Designated Aliases @@ -1401,7 +1401,7 @@ In this example, the `id` field contains the [[ref: SAID]] of the seal in the de #### Designated Aliases event details -::: informative Designated aliases example +::: informative Designated aliases example This is an example [[ref: designated aliases]] [[ref: ACDC]] attestation showing five designated aliases: ```json @@ -1479,7 +1479,7 @@ The resulting DID document based on the [[ref: designated aliases]] attestation } ``` -::: +::: --- @@ -1621,7 +1621,7 @@ This allows clients to instruct a DID Resolver to return a specific version of a 1. When a `did:webs` DID is resolved with this DID parameter, a `did:webs` resolver MUST construct the DID document based on an AID's associated KERI events from the KERI event stream only up to (and including) the event with the sequence number (i.e. the `s` field) that corresponds to the value of the `versionId` DID parameter. -::: informative versionId example +::: informative versionId example See section [DID Documents](#did-documents) for details. Example: @@ -1630,7 +1630,7 @@ Example: did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M?versionId=1 ``` -::: +::: ### Support for `transformKeys` @@ -1638,14 +1638,14 @@ The `did:webs` DID method supports the `transformKeys` DID parameter. This DID p 1. This parameter MUST be implemented for a DID Resolver to return verification methods in a DID document in a desired format, such as `JsonWebKey` or `Ed25519VerificationKey2020`. -::: informative transformKeys example +::: informative transformKeys example Example: ``` did:webs:example.com:Ew-o5dU5WjDrxDBK4b4HrF82_rYb6MX6xsegjq4n0Y7M?transformKeys=CesrKey ``` -::: +::: #### `CesrKey` and `publicKeyCesr` @@ -1655,7 +1655,7 @@ This specification defines the following extensions to the DID document data mod 1. Extension verification method property `publicKeyCesr` MAY be available in a `did:webs` DID document to provide a string value whose content is the CESR representation of a public key. 1. The verification method type `CesrKey` MAY be used as the value of the `transformKeys` DID parameter. -::: informative CesrKey example +::: informative CesrKey example For example, a KERI AID with only the following inception event in its KEL: ```json @@ -1708,7 +1708,7 @@ would result in a DID document with the following verification methods array: } ``` -::: +::: --- @@ -1789,7 +1789,7 @@ The following security concepts are used to secure the data, files, signatures a #### Reducing the attack surface -::: informative Reducing the attack surface +::: informative Reducing the attack surface The above considerations have lead us to focus on KEL backed DID document blocks and data (designated alias ACDCs, signatures, etc) so that the trusted (local) did:webs resolver is secure. Any future features that could leverage BADA-RUN and [[ref: KRAM]] should be considered carefully according to the above considerations. @@ -1799,7 +1799,7 @@ See the implementors guide for more details about KEL backed, BADA-RUN, and KRAM * [Alignment of Information to Security Posture](#alignment-of-information-to-security-posture) * [Applying the concepts of KEL, BADA-RUN, and KRAM](#applying-the-concepts-of-kel) -::: +::: --- @@ -1807,7 +1807,7 @@ See the implementors guide for more details about KEL backed, BADA-RUN, and KRAM ## Privacy Considerations -::: informative Privacy Considerations +::: informative Privacy Considerations This section addresses the privacy considerations from [RFC6973](https://datatracker.ietf.org/doc/html/rfc6973) section 5. For privacy considerations related to web infrastructure, see [`did:web` privacy considerations](https://w3c-ccg.github.io/did-method-web/#security-and-privacy-considerations). @@ -1866,6 +1866,6 @@ This DID method provides no opportunity for [correlation](#correlation), [identi [disclosure](#disclosure) and therefore there is no opportunity to exclude the controller from knowing about data that others have about them. -::: +::: --- diff --git a/mkdocs.yml b/mkdocs.yml index aeeca49..feb9256 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -76,10 +76,13 @@ nav: - API Reference: - Python: api/python/index.md - TypeScript: api/typescript/index.md + - Schema: + - Credential Data Model: schema/credential-model.md - Architecture: - Overview: architecture.md - ADR-001 VC Securing Mechanism: decisions/001-vc-securing-mechanism.md - ADR-002 Dual Runtime: decisions/002-dual-runtime-architecture.md - ADR-003 Canonicalization: decisions/003-canonicalization.md - ADR-004 Key Management: decisions/004-key-management.md + - ADR-005 did:ethr Migration: decisions/005-did-ethr-migration.md - Contributing: contributing.md From 40056f516f17182e26fa6852023a2c98ee99e4f5 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Tue, 10 Mar 2026 16:51:48 +0100 Subject: [PATCH 20/78] fix(linkml): add did: prefix to resolve namespace warnings Add did: https://www.w3.org/ns/did# to prefixes and replace 6 full URIs with compact did: notation (controller, serviceEndpoint, DIDDocument, service, verificationMethod, VerificationMethod). Eliminates 'No namespace defined for URI' warnings during make generate. Signed-off-by: Carlo van Driesten --- linkml/harbour-core-credential.yaml | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/linkml/harbour-core-credential.yaml b/linkml/harbour-core-credential.yaml index b69b6f8..4f7270a 100644 --- a/linkml/harbour-core-credential.yaml +++ b/linkml/harbour-core-credential.yaml @@ -53,6 +53,7 @@ prefixes: cs: https://www.w3.org/ns/credentials/status# cred: https://www.w3.org/2018/credentials# schema: http://schema.org/ + did: https://www.w3.org/ns/did# default_prefix: harbour default_range: string @@ -87,13 +88,13 @@ slots: # entity authorized to make changes to the DID document. # In practice always a DID (did:ethr:..., did:key:..., etc.). controller: - slot_uri: https://www.w3.org/ns/did#controller + slot_uri: did:controller range: uri # [DID-CORE] §5.4 — serviceEndpoint can be a URI, map, or set. # Each service entry MUST have id, type, and serviceEndpoint. serviceEndpoint: - slot_uri: https://www.w3.org/ns/did#serviceEndpoint + slot_uri: did:serviceEndpoint range: Any required: true @@ -217,14 +218,14 @@ classes: # [DID-CORE] §4 — DID Document is a set of data describing the DID subject. # Properties: id (REQUIRED), controller, verificationMethod, service, etc. DIDDocument: - class_uri: https://www.w3.org/ns/did#DIDDocument + class_uri: did:DIDDocument slots: - controller attributes: # [DID-CORE] §5.4 — service is OPTIONAL, each entry MUST have id, type, # and serviceEndpoint. service values MUST be unique. service: - slot_uri: https://www.w3.org/ns/did#service + slot_uri: did:service multivalued: true inlined: true range: ServiceUnion @@ -232,7 +233,7 @@ classes: # controller, and key material (publicKeyJwk or publicKeyMultibase). # Harbour models a subset (id, controller, blockchainAccountId). verificationMethod: - slot_uri: https://www.w3.org/ns/did#verificationMethod + slot_uri: did:verificationMethod multivalued: true range: VerificationMethod @@ -435,7 +436,7 @@ classes: # [VC-JOSE-COSE] §4.2 — verification method type MUST be JsonWebKey; key # material MUST be in publicKeyJwk property. VerificationMethod: - class_uri: https://www.w3.org/ns/did#VerificationMethod + class_uri: did:VerificationMethod slots: - controller attributes: From 012742dea2a1ad16e60cf5ec4141428ce8dd764c Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Tue, 10 Mar 2026 17:07:07 +0100 Subject: [PATCH 21/78] refactor: use context-mapped short names in examples and mappings Replace schema:-prefixed property names (schema:givenName, schema:familyName, schema:email, schema:name) with their short forms (givenName, familyName, email, name) in all example credentials. The generated JSON-LD context already maps these short names to their schema.org IRIs, making the schema: prefix redundant. This simplifies example readability without changing semantics. - Update 6 example credential files - Update claim_mapping.py paths to match - Update test assertions accordingly - schema:taxID kept prefixed (not mapped in context) Signed-off-by: Carlo van Driesten --- examples/gaiax/legal-person-credential-bmw.json | 2 +- examples/gaiax/legal-person-credential.json | 4 ++-- .../gaiax/natural-person-credential-andreas.json | 10 +++++----- .../gaiax/natural-person-credential-max.json | 10 +++++----- examples/gaiax/natural-person-credential.json | 10 +++++----- examples/natural-person-credential.json | 6 +++--- src/python/credentials/claim_mapping.py | 16 ++++++++-------- tests/python/credentials/test_claim_mapping.py | 16 ++++++++-------- 8 files changed, 37 insertions(+), 37 deletions(-) diff --git a/examples/gaiax/legal-person-credential-bmw.json b/examples/gaiax/legal-person-credential-bmw.json index 5e7322c..8adf6d1 100644 --- a/examples/gaiax/legal-person-credential-bmw.json +++ b/examples/gaiax/legal-person-credential-bmw.json @@ -17,7 +17,7 @@ "type": "harbour:LegalPerson", "gxParticipant": { "type": "gx:LegalPerson", - "schema:name": "Bayerische Motoren Werke Aktiengesellschaft", + "name": "Bayerische Motoren Werke Aktiengesellschaft", "gx:registrationNumber": [ { "type": "gx:RegistrationNumber", diff --git a/examples/gaiax/legal-person-credential.json b/examples/gaiax/legal-person-credential.json index a352962..32a3c95 100644 --- a/examples/gaiax/legal-person-credential.json +++ b/examples/gaiax/legal-person-credential.json @@ -17,7 +17,7 @@ "type": "harbour:LegalPerson", "gxParticipant": { "type": "gx:LegalPerson", - "schema:name": "Example Corporation GmbH", + "name": "Example Corporation GmbH", "gx:registrationNumber": { "type": "gx:RegistrationNumber", "schema:taxID": "DE123456789" @@ -67,7 +67,7 @@ "type": "harbour:LegalPerson", "gxParticipant": { "type": "gx:LegalPerson", - "schema:name": "ReachHaven GmbH" + "name": "ReachHaven GmbH" } } } diff --git a/examples/gaiax/natural-person-credential-andreas.json b/examples/gaiax/natural-person-credential-andreas.json index 6c2732f..42bbbc4 100644 --- a/examples/gaiax/natural-person-credential-andreas.json +++ b/examples/gaiax/natural-person-credential-andreas.json @@ -16,13 +16,13 @@ "id": "did:ethr:0x14a34:0xb2F78332cF29Bd4dBB04Dea2EF59439F43F0b39a", "type": "harbour:NaturalPerson", "name": "Andreas Admin", - "schema:givenName": "Andreas", - "schema:familyName": "Admin", - "schema:email": "andreas.admin@bmw.com", + "givenName": "Andreas", + "familyName": "Admin", + "email": "andreas.admin@bmw.com", "memberOf": "did:ethr:0x14a34:0x9d273DCaC2f6367968d61caf69A7E3177fd81048", "gxParticipant": { "type": "gx:Participant", - "schema:name": "Andreas Admin" + "name": "Andreas Admin" } }, "credentialStatus": [ @@ -56,7 +56,7 @@ "type": "harbour:LegalPerson", "gxParticipant": { "type": "gx:LegalPerson", - "schema:name": "Bayerische Motoren Werke Aktiengesellschaft" + "name": "Bayerische Motoren Werke Aktiengesellschaft" } } } diff --git a/examples/gaiax/natural-person-credential-max.json b/examples/gaiax/natural-person-credential-max.json index 9a94b4c..6c8bc0c 100644 --- a/examples/gaiax/natural-person-credential-max.json +++ b/examples/gaiax/natural-person-credential-max.json @@ -16,13 +16,13 @@ "id": "did:ethr:0x14a34:0x0f4Dc6903A4B92C6563DD3551421ebb7ACa7d4fC", "type": "harbour:NaturalPerson", "name": "Max Mustermann", - "schema:givenName": "Max", - "schema:familyName": "Mustermann", - "schema:email": "max.mustermann@bmw.com", + "givenName": "Max", + "familyName": "Mustermann", + "email": "max.mustermann@bmw.com", "memberOf": "did:ethr:0x14a34:0x9d273DCaC2f6367968d61caf69A7E3177fd81048", "gxParticipant": { "type": "gx:Participant", - "schema:name": "Max Mustermann" + "name": "Max Mustermann" } }, "credentialStatus": [ @@ -56,7 +56,7 @@ "type": "harbour:LegalPerson", "gxParticipant": { "type": "gx:LegalPerson", - "schema:name": "Bayerische Motoren Werke Aktiengesellschaft" + "name": "Bayerische Motoren Werke Aktiengesellschaft" } } } diff --git a/examples/gaiax/natural-person-credential.json b/examples/gaiax/natural-person-credential.json index 3f653c6..9f224ee 100644 --- a/examples/gaiax/natural-person-credential.json +++ b/examples/gaiax/natural-person-credential.json @@ -15,13 +15,13 @@ "credentialSubject": { "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", "type": "harbour:NaturalPerson", - "schema:givenName": "Alice", - "schema:familyName": "Smith", - "schema:email": "alice.smith@example.com", + "givenName": "Alice", + "familyName": "Smith", + "email": "alice.smith@example.com", "memberOf": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", "gxParticipant": { "type": "gx:Participant", - "schema:name": "Alice Smith" + "name": "Alice Smith" } }, "credentialStatus": [ @@ -55,7 +55,7 @@ "type": "harbour:LegalPerson", "gxParticipant": { "type": "gx:LegalPerson", - "schema:name": "Example Corporation GmbH" + "name": "Example Corporation GmbH" } } } diff --git a/examples/natural-person-credential.json b/examples/natural-person-credential.json index e776414..dd9fbb0 100644 --- a/examples/natural-person-credential.json +++ b/examples/natural-person-credential.json @@ -15,9 +15,9 @@ "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", "type": "harbour:NaturalPerson", "name": "Alice Smith", - "schema:givenName": "Alice", - "schema:familyName": "Smith", - "schema:email": "alice.smith@example.com", + "givenName": "Alice", + "familyName": "Smith", + "email": "alice.smith@example.com", "memberOf": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" }, "credentialStatus": [ diff --git a/src/python/credentials/claim_mapping.py b/src/python/credentials/claim_mapping.py index 1752ddc..0c81b59 100644 --- a/src/python/credentials/claim_mapping.py +++ b/src/python/credentials/claim_mapping.py @@ -44,9 +44,9 @@ HARBOUR_NATURAL_PERSON_MAPPING = { "vct": f"{HARBOUR_NS}NaturalPersonCredential", "claims": { - "credentialSubject.schema:givenName": "givenName", - "credentialSubject.schema:familyName": "familyName", - "credentialSubject.schema:email": "email", + "credentialSubject.givenName": "givenName", + "credentialSubject.familyName": "familyName", + "credentialSubject.email": "email", "credentialSubject.memberOf": "memberOf", }, "always_disclosed": ["iss", "vct", "iat", "exp"], @@ -60,7 +60,7 @@ GAIAX_LEGAL_PERSON_MAPPING = { "vct": f"{HARBOUR_NS}LegalPersonCredential", "claims": { - "credentialSubject.gxParticipant.schema:name": "legalName", + "credentialSubject.gxParticipant.name": "legalName", "credentialSubject.gxParticipant.gx:registrationNumber": "registrationNumber", "credentialSubject.gxParticipant.gx:headquartersAddress": "headquartersAddress", "credentialSubject.gxParticipant.gx:legalAddress": "legalAddress", @@ -76,10 +76,10 @@ GAIAX_NATURAL_PERSON_MAPPING = { "vct": f"{HARBOUR_NS}NaturalPersonCredential", "claims": { - "credentialSubject.gxParticipant.schema:name": "gxName", - "credentialSubject.schema:givenName": "givenName", - "credentialSubject.schema:familyName": "familyName", - "credentialSubject.schema:email": "email", + "credentialSubject.gxParticipant.name": "gxName", + "credentialSubject.givenName": "givenName", + "credentialSubject.familyName": "familyName", + "credentialSubject.email": "email", "credentialSubject.memberOf": "memberOf", }, "always_disclosed": ["iss", "vct", "iat", "exp"], diff --git a/tests/python/credentials/test_claim_mapping.py b/tests/python/credentials/test_claim_mapping.py index c577ffc..a30c03f 100644 --- a/tests/python/credentials/test_claim_mapping.py +++ b/tests/python/credentials/test_claim_mapping.py @@ -138,8 +138,8 @@ def test_roundtrip(self): claims, mapping, "harbour:NaturalPersonCredential" ) assert ( - reconstructed["credentialSubject"]["schema:givenName"] - == vc["credentialSubject"]["schema:givenName"] + reconstructed["credentialSubject"]["givenName"] + == vc["credentialSubject"]["givenName"] ) @@ -154,7 +154,7 @@ def test_vc_to_claims(self): mapping = GAIAX_MAPPINGS["harbour:LegalPersonCredential"] claims, disclosable = vc_to_sd_jwt_claims(vc, mapping) - # Gaia-X extension uses gxParticipant.schema:name, not outer name + # Gaia-X extension uses gxParticipant.name, not outer name assert "name" not in claims assert claims["legalName"] == "Example Corporation GmbH" assert "registrationNumber" in claims @@ -166,7 +166,7 @@ def test_gx_inner_node_exists(self): subject = vc["credentialSubject"] gx = subject["gxParticipant"] assert gx["type"] == "gx:LegalPerson" - assert "schema:name" in gx + assert "name" in gx assert "gx:registrationNumber" in gx # gx properties must NOT be on the outer node assert "gx:registrationNumber" not in subject @@ -189,8 +189,8 @@ def test_roundtrip(self): claims, mapping, "harbour:LegalPersonCredential" ) assert ( - reconstructed["credentialSubject"]["gxParticipant"]["schema:name"] - == vc["credentialSubject"]["gxParticipant"]["schema:name"] + reconstructed["credentialSubject"]["gxParticipant"]["name"] + == vc["credentialSubject"]["gxParticipant"]["name"] ) @@ -235,7 +235,7 @@ def test_get_mapping_for_harbour_skeleton(self): assert mapping is not None assert "LegalPersonCredential" in mapping["vct"] # Base mapping should NOT have gxParticipant paths - assert "credentialSubject.gxParticipant.schema:name" not in mapping["claims"] + assert "credentialSubject.gxParticipant.name" not in mapping["claims"] def test_get_mapping_for_gaiax_extension(self): """Gaia-X extension (with Gaia-X context) should return Gaia-X mapping.""" @@ -244,7 +244,7 @@ def test_get_mapping_for_gaiax_extension(self): assert mapping is not None assert "LegalPersonCredential" in mapping["vct"] # Gaia-X mapping should have gxParticipant paths - assert "credentialSubject.gxParticipant.schema:name" in mapping["claims"] + assert "credentialSubject.gxParticipant.name" in mapping["claims"] def test_get_mapping_for_unknown(self): vc = {"type": ["VerifiableCredential", "UnknownType"]} From 540e70ad6a6f6d9b56c3e73d19a8a13396729b63 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Tue, 10 Mar 2026 21:48:30 +0100 Subject: [PATCH 22/78] refactor: consolidate examples and make skeletons minimal - Make LegalPerson skeleton minimal (DID + type only, no name) - Remove redundant examples: natural-person-credential-andreas, natural-person-credential-max, legal-person-credential-bmw - Keep one generic Gaia-X example per type (Example Corp + Alice Smith) - Update LegalPerson claim mapping (skeleton has no claims) - Update schema description for LegalPerson skeleton pattern - Add test_no_name_on_outer_node for LegalPerson skeleton Signed-off-by: Carlo van Driesten --- .../gaiax/legal-person-credential-bmw.json | 63 --------------- .../natural-person-credential-andreas.json | 67 ---------------- .../gaiax/natural-person-credential-max.json | 67 ---------------- examples/gaiax/natural-person-credential.json | 13 ++-- examples/legal-person-credential.json | 6 +- examples/natural-person-credential.json | 4 - linkml/harbour-gx-credential.yaml | 39 ++++------ src/python/credentials/claim_mapping.py | 20 ++--- .../python/credentials/test_claim_mapping.py | 77 +++++++++++-------- 9 files changed, 73 insertions(+), 283 deletions(-) delete mode 100644 examples/gaiax/legal-person-credential-bmw.json delete mode 100644 examples/gaiax/natural-person-credential-andreas.json delete mode 100644 examples/gaiax/natural-person-credential-max.json diff --git a/examples/gaiax/legal-person-credential-bmw.json b/examples/gaiax/legal-person-credential-bmw.json deleted file mode 100644 index 8adf6d1..0000000 --- a/examples/gaiax/legal-person-credential-bmw.json +++ /dev/null @@ -1,63 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/gaia-x/development#", - "https://w3id.org/reachhaven/harbour/credentials/v1/" - ], - "type": [ - "VerifiableCredential", - "harbour:LegalPersonCredential" - ], - "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", - "validFrom": "2025-01-15T00:00:00Z", - "validUntil": "2030-01-15T00:00:00Z", - "credentialSubject": { - "id": "did:ethr:0x14a34:0x9d273DCaC2f6367968d61caf69A7E3177fd81048", - "type": "harbour:LegalPerson", - "gxParticipant": { - "type": "gx:LegalPerson", - "name": "Bayerische Motoren Werke Aktiengesellschaft", - "gx:registrationNumber": [ - { - "type": "gx:RegistrationNumber", - "gx:vatID": "DE129273398" - } - ], - "gx:legalAddress": { - "type": "gx:Address", - "gx:countryCode": "DE", - "gx:countryName": "Germany", - "vcard:street-address": "Petuelring 130", - "vcard:postal-code": "80809", - "vcard:locality": "München" - }, - "gx:headquartersAddress": { - "type": "gx:Address", - "gx:countryCode": "DE", - "gx:countryName": "Germany", - "vcard:street-address": "Petuelring 130", - "vcard:postal-code": "80809", - "vcard:locality": "München" - } - } - }, - "credentialStatus": [ - { - "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/a1b2c3d4e5f67890", - "type": "harbour:CRSetEntry", - "statusPurpose": "revocation" - } - ], - "evidence": [ - { - "type": "harbour:CredentialEvidence", - "verifiablePresentation": { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiablePresentation"], - "holder": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", - "nonce": "c7821a0b ISSUE_PAYLOAD 9e5f3a2d1b7c4e6f8a0d2c4b6e8f0a2c4d6e8b0f2a4c6d8e0b2a4c6d8e0f2a" - } - } - ] -} diff --git a/examples/gaiax/natural-person-credential-andreas.json b/examples/gaiax/natural-person-credential-andreas.json deleted file mode 100644 index 42bbbc4..0000000 --- a/examples/gaiax/natural-person-credential-andreas.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/gaia-x/development#", - "https://w3id.org/reachhaven/harbour/credentials/v1/" - ], - "type": [ - "VerifiableCredential", - "harbour:NaturalPersonCredential" - ], - "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f23456789012", - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", - "validFrom": "2025-01-15T00:00:00Z", - "validUntil": "2030-01-15T00:00:00Z", - "credentialSubject": { - "id": "did:ethr:0x14a34:0xb2F78332cF29Bd4dBB04Dea2EF59439F43F0b39a", - "type": "harbour:NaturalPerson", - "name": "Andreas Admin", - "givenName": "Andreas", - "familyName": "Admin", - "email": "andreas.admin@bmw.com", - "memberOf": "did:ethr:0x14a34:0x9d273DCaC2f6367968d61caf69A7E3177fd81048", - "gxParticipant": { - "type": "gx:Participant", - "name": "Andreas Admin" - } - }, - "credentialStatus": [ - { - "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/b2c3d4e5f6a78901", - "type": "harbour:CRSetEntry", - "statusPurpose": "revocation" - } - ], - "evidence": [ - { - "type": "harbour:CredentialEvidence", - "verifiablePresentation": { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiablePresentation"], - "holder": "did:ethr:0x14a34:0x9d273DCaC2f6367968d61caf69A7E3177fd81048", - "verifiableCredential": [ - { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/gaia-x/development#", - "https://w3id.org/reachhaven/harbour/credentials/v1/" - ], - "type": [ - "VerifiableCredential", - "harbour:LegalPersonCredential" - ], - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", - "credentialSubject": { - "id": "did:ethr:0x14a34:0x9d273DCaC2f6367968d61caf69A7E3177fd81048", - "type": "harbour:LegalPerson", - "gxParticipant": { - "type": "gx:LegalPerson", - "name": "Bayerische Motoren Werke Aktiengesellschaft" - } - } - } - ] - } - } - ] -} diff --git a/examples/gaiax/natural-person-credential-max.json b/examples/gaiax/natural-person-credential-max.json deleted file mode 100644 index 6c8bc0c..0000000 --- a/examples/gaiax/natural-person-credential-max.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/gaia-x/development#", - "https://w3id.org/reachhaven/harbour/credentials/v1/" - ], - "type": [ - "VerifiableCredential", - "harbour:NaturalPersonCredential" - ], - "id": "urn:uuid:c3d4e5f6-a7b8-9012-cdef-345678901234", - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", - "validFrom": "2025-01-15T00:00:00Z", - "validUntil": "2030-01-15T00:00:00Z", - "credentialSubject": { - "id": "did:ethr:0x14a34:0x0f4Dc6903A4B92C6563DD3551421ebb7ACa7d4fC", - "type": "harbour:NaturalPerson", - "name": "Max Mustermann", - "givenName": "Max", - "familyName": "Mustermann", - "email": "max.mustermann@bmw.com", - "memberOf": "did:ethr:0x14a34:0x9d273DCaC2f6367968d61caf69A7E3177fd81048", - "gxParticipant": { - "type": "gx:Participant", - "name": "Max Mustermann" - } - }, - "credentialStatus": [ - { - "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/c3d4e5f6a7b89012", - "type": "harbour:CRSetEntry", - "statusPurpose": "revocation" - } - ], - "evidence": [ - { - "type": "harbour:CredentialEvidence", - "verifiablePresentation": { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiablePresentation"], - "holder": "did:ethr:0x14a34:0x9d273DCaC2f6367968d61caf69A7E3177fd81048", - "verifiableCredential": [ - { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/gaia-x/development#", - "https://w3id.org/reachhaven/harbour/credentials/v1/" - ], - "type": [ - "VerifiableCredential", - "harbour:LegalPersonCredential" - ], - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", - "credentialSubject": { - "id": "did:ethr:0x14a34:0x9d273DCaC2f6367968d61caf69A7E3177fd81048", - "type": "harbour:LegalPerson", - "gxParticipant": { - "type": "gx:LegalPerson", - "name": "Bayerische Motoren Werke Aktiengesellschaft" - } - } - } - ] - } - } - ] -} diff --git a/examples/gaiax/natural-person-credential.json b/examples/gaiax/natural-person-credential.json index 9f224ee..6663519 100644 --- a/examples/gaiax/natural-person-credential.json +++ b/examples/gaiax/natural-person-credential.json @@ -15,14 +15,13 @@ "credentialSubject": { "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", "type": "harbour:NaturalPerson", - "givenName": "Alice", - "familyName": "Smith", - "email": "alice.smith@example.com", - "memberOf": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", "gxParticipant": { - "type": "gx:Participant", - "name": "Alice Smith" - } + "type": "gx:NaturalPerson", + "givenName": "Alice", + "familyName": "Smith", + "email": "alice.smith@example.com" + }, + "memberOf": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" }, "credentialStatus": [ { diff --git a/examples/legal-person-credential.json b/examples/legal-person-credential.json index 9f8a9f2..c16b62b 100644 --- a/examples/legal-person-credential.json +++ b/examples/legal-person-credential.json @@ -13,8 +13,7 @@ "validUntil": "2025-01-15T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", - "type": "harbour:LegalPerson", - "name": "Example Corporation GmbH" + "type": "harbour:LegalPerson" }, "credentialStatus": [ { @@ -43,8 +42,7 @@ "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "credentialSubject": { "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", - "type": "harbour:LegalPerson", - "name": "ReachHaven GmbH" + "type": "harbour:LegalPerson" } } ] diff --git a/examples/natural-person-credential.json b/examples/natural-person-credential.json index dd9fbb0..4d7ab42 100644 --- a/examples/natural-person-credential.json +++ b/examples/natural-person-credential.json @@ -14,10 +14,6 @@ "credentialSubject": { "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", "type": "harbour:NaturalPerson", - "name": "Alice Smith", - "givenName": "Alice", - "familyName": "Smith", - "email": "alice.smith@example.com", "memberOf": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" }, "credentialStatus": [ diff --git a/linkml/harbour-gx-credential.yaml b/linkml/harbour-gx-credential.yaml index 8234fda..dba0ea5 100644 --- a/linkml/harbour-gx-credential.yaml +++ b/linkml/harbour-gx-credential.yaml @@ -64,7 +64,7 @@ slots: gxParticipant: description: > Nested Gaia-X participant compliance data - (gx:LegalPerson or gx:Participant blank node). + (gx:LegalPerson or gx:NaturalPerson blank node). slot_uri: harbour:gxParticipant range: Any required: false @@ -104,9 +104,10 @@ classes: is_a: HarbourCredential description: > Credential attesting to a harbour:NaturalPerson (individual) identity. - In skeleton form the credentialSubject carries only harbour properties - (name, givenName, familyName, email, memberOf). When Gaia-X compliance - is needed, gxParticipant carries the gx:Participant blank node. + Personal attributes (givenName, familyName, email) live inside the + gxParticipant node as a gx:NaturalPerson blank node (harbour-defined + extension of gx:Participant). The outer harbour node carries only + memberOf (organization link) and the gxParticipant composition slot. class_uri: harbour:NaturalPersonCredential annotations: # [SD-JWT-VC] §3.2.2.1 — vct MUST be a case-sensitive StringOrURI. @@ -131,9 +132,9 @@ classes: LegalPerson: description: > A legal person (organization) participating in the harbour ecosystem. - In skeleton form only the harbour name property is present. When Gaia-X - compliance is needed, name is omitted from the outer node and schema:name - lives in the gxParticipant inner node (gx:LegalPerson blank node), + In skeleton form the outer node carries only the DID subject identifier. + When Gaia-X compliance is needed, schema:name and all required gx:LegalPerson + attributes live in the gxParticipant inner node (gx:LegalPerson blank node), keeping the gx closed shape intact. class_uri: harbour:LegalPerson slots: @@ -146,29 +147,15 @@ classes: NaturalPerson: description: > A natural person (individual) participating in the harbour ecosystem. - In skeleton form only harbour properties are present. When Gaia-X - compliance is needed, name is omitted and schema:name lives in the - gxParticipant inner node (gx:Participant blank node). + Personal attributes (givenName, familyName, email) live inside the + gxParticipant node as a gx:NaturalPerson blank node — a harbour-defined + extension of gx:Participant (which is sh:closed false). + The outer node carries only the organization link (memberOf) and the + composition slot (gxParticipant). class_uri: harbour:NaturalPerson slots: - - name - gxParticipant - slot_usage: - name: - required: false attributes: - # [SCHEMA-ORG] — https://schema.org/givenName - givenName: - slot_uri: schema:givenName - range: string - # [SCHEMA-ORG] — https://schema.org/familyName - familyName: - slot_uri: schema:familyName - range: string - # [SCHEMA-ORG] — https://schema.org/email - email: - slot_uri: schema:email - range: string # [SCHEMA-ORG] — https://schema.org/memberOf memberOf: description: Organization (LegalPerson) the natural person belongs to. diff --git a/src/python/credentials/claim_mapping.py b/src/python/credentials/claim_mapping.py index 0c81b59..c410792 100644 --- a/src/python/credentials/claim_mapping.py +++ b/src/python/credentials/claim_mapping.py @@ -34,19 +34,17 @@ HARBOUR_LEGAL_PERSON_MAPPING = { "vct": f"{HARBOUR_NS}LegalPersonCredential", - "claims": { - "credentialSubject.name": "name", - }, - "always_disclosed": ["iss", "vct", "iat", "exp", "name"], + "claims": {}, + "always_disclosed": ["iss", "vct", "iat", "exp"], "selectively_disclosed": [], } HARBOUR_NATURAL_PERSON_MAPPING = { "vct": f"{HARBOUR_NS}NaturalPersonCredential", "claims": { - "credentialSubject.givenName": "givenName", - "credentialSubject.familyName": "familyName", - "credentialSubject.email": "email", + "credentialSubject.gxParticipant.givenName": "givenName", + "credentialSubject.gxParticipant.familyName": "familyName", + "credentialSubject.gxParticipant.email": "email", "credentialSubject.memberOf": "memberOf", }, "always_disclosed": ["iss", "vct", "iat", "exp"], @@ -76,15 +74,13 @@ GAIAX_NATURAL_PERSON_MAPPING = { "vct": f"{HARBOUR_NS}NaturalPersonCredential", "claims": { - "credentialSubject.gxParticipant.name": "gxName", - "credentialSubject.givenName": "givenName", - "credentialSubject.familyName": "familyName", - "credentialSubject.email": "email", + "credentialSubject.gxParticipant.givenName": "givenName", + "credentialSubject.gxParticipant.familyName": "familyName", + "credentialSubject.gxParticipant.email": "email", "credentialSubject.memberOf": "memberOf", }, "always_disclosed": ["iss", "vct", "iat", "exp"], "selectively_disclosed": [ - "gxName", "givenName", "familyName", "email", diff --git a/tests/python/credentials/test_claim_mapping.py b/tests/python/credentials/test_claim_mapping.py index a30c03f..831db21 100644 --- a/tests/python/credentials/test_claim_mapping.py +++ b/tests/python/credentials/test_claim_mapping.py @@ -36,6 +36,7 @@ def _load_fixture(name: str, gaiax: bool = False) -> dict: class TestHarbourLegalPersonMapping: def test_vc_to_claims(self): + """Skeleton LegalPerson is minimal — no name, no gxParticipant.""" vc = _load_fixture("legal-person-credential.json") mapping = MAPPINGS["harbour:LegalPersonCredential"] claims, disclosable = vc_to_sd_jwt_claims(vc, mapping) @@ -48,8 +49,8 @@ def test_vc_to_claims(self): claims["sub"] == "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" ) - assert claims["name"] == "Example Corporation GmbH" - # Base mapping has no gx claims + # Skeleton has no name — identity data lives in gxParticipant (Gaia-X layer) + assert "name" not in claims assert "legalName" not in claims assert "registrationNumber" not in claims assert disclosable == [] @@ -72,35 +73,43 @@ def test_no_gx_participant(self): vc = _load_fixture("legal-person-credential.json") assert "gxParticipant" not in vc["credentialSubject"] + def test_no_name_on_outer_node(self): + """Base skeleton must not have name on the outer node.""" + vc = _load_fixture("legal-person-credential.json") + assert "name" not in vc["credentialSubject"] + def test_no_gaiax_context(self): """Base skeleton must not reference the Gaia-X namespace.""" vc = _load_fixture("legal-person-credential.json") assert GAIAX_NS not in vc["@context"] def test_roundtrip(self): + """Roundtrip skeleton — no claims to map, just envelope.""" vc = _load_fixture("legal-person-credential.json") mapping = MAPPINGS["harbour:LegalPersonCredential"] claims, _ = vc_to_sd_jwt_claims(vc, mapping) reconstructed = sd_jwt_claims_to_vc( claims, mapping, "harbour:LegalPersonCredential" ) - assert ( - reconstructed["credentialSubject"]["name"] - == vc["credentialSubject"]["name"] - ) + # Skeleton has no name — just verify subject ID roundtrips + assert reconstructed["credentialSubject"]["id"] == vc["credentialSubject"]["id"] class TestHarbourNaturalPersonMapping: def test_vc_to_claims(self): + """Skeleton NaturalPerson is minimal — just memberOf, no personal attributes.""" vc = _load_fixture("natural-person-credential.json") mapping = MAPPINGS["harbour:NaturalPersonCredential"] claims, disclosable = vc_to_sd_jwt_claims(vc, mapping) - assert claims["givenName"] == "Alice" - assert claims["familyName"] == "Smith" - assert claims["email"] == "alice.smith@example.com" - assert "givenName" in disclosable - assert "email" in disclosable + assert ( + claims["memberOf"] + == "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" + ) + assert "memberOf" in disclosable + # No personal attributes in skeleton — those come via gxParticipant + assert "givenName" not in claims + assert "familyName" not in claims def test_has_credential_status(self): vc = _load_fixture("natural-person-credential.json") @@ -115,33 +124,30 @@ def test_has_evidence(self): assert evidence["type"] == "harbour:CredentialEvidence" def test_subject_is_harbour_natural_person(self): - """Verify the subject uses harbour:NaturalPerson (outer node only).""" + """Verify the subject uses harbour:NaturalPerson (outer node).""" vc = _load_fixture("natural-person-credential.json") subject_type = vc["credentialSubject"]["type"] assert subject_type == "harbour:NaturalPerson" def test_no_gx_participant(self): - """Base skeleton must not contain gxParticipant.""" + """Skeleton is minimal — no gxParticipant.""" vc = _load_fixture("natural-person-credential.json") assert "gxParticipant" not in vc["credentialSubject"] + def test_no_personal_attributes(self): + """Skeleton carries no personal attributes — identity comes via gx layer.""" + vc = _load_fixture("natural-person-credential.json") + subject = vc["credentialSubject"] + assert "givenName" not in subject + assert "familyName" not in subject + assert "email" not in subject + assert "name" not in subject + def test_no_gaiax_context(self): """Base skeleton must not reference the Gaia-X namespace.""" vc = _load_fixture("natural-person-credential.json") assert GAIAX_NS not in vc["@context"] - def test_roundtrip(self): - vc = _load_fixture("natural-person-credential.json") - mapping = MAPPINGS["harbour:NaturalPersonCredential"] - claims, _ = vc_to_sd_jwt_claims(vc, mapping) - reconstructed = sd_jwt_claims_to_vc( - claims, mapping, "harbour:NaturalPersonCredential" - ) - assert ( - reconstructed["credentialSubject"]["givenName"] - == vc["credentialSubject"]["givenName"] - ) - # --------------------------------------------------------------------------- # Gaia-X domain extension tests @@ -201,20 +207,25 @@ def test_vc_to_claims(self): claims, disclosable = vc_to_sd_jwt_claims(vc, mapping) assert claims["givenName"] == "Alice" - assert claims["gxName"] == "Alice Smith" - assert "gxName" in disclosable + assert claims["familyName"] == "Smith" + assert "givenName" in disclosable - def test_gx_inner_node_exists(self): - """Verify gx:Participant data lives in the gxParticipant inner node.""" + def test_gx_natural_person_inner_node(self): + """Verify gx:NaturalPerson data lives in the gxParticipant inner node.""" vc = _load_fixture("natural-person-credential.json", gaiax=True) subject = vc["credentialSubject"] gx = subject["gxParticipant"] - assert gx["type"] == "gx:Participant" + assert gx["type"] == "gx:NaturalPerson" + assert "givenName" in gx + assert "familyName" in gx - def test_no_outer_name(self): - """Gaia-X extension should NOT have name on the outer node.""" + def test_no_personal_attributes_on_outer_node(self): + """givenName/familyName/email must NOT be on the outer node.""" vc = _load_fixture("natural-person-credential.json", gaiax=True) - assert "name" not in vc["credentialSubject"] + subject = vc["credentialSubject"] + assert "givenName" not in subject + assert "familyName" not in subject + assert "name" not in subject def test_has_gaiax_context(self): """Gaia-X extension must include the Gaia-X namespace in @context.""" From 3e61f498a420d70b1e5384b82b46f8c676503c0e Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Wed, 11 Mar 2026 16:53:37 +0100 Subject: [PATCH 23/78] refactor(schemas): namespace refactoring, example reorganization, test updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename `did:` prefix to `didcore:` to avoid collision with DID URI scheme - Add `harbour_gx:` namespace for Gaia-X domain layer types - Change harbour-gx-credential default_prefix from `harbour` to `harbour_gx` - Update all class_uri references in gx layer to use `harbour_gx:` prefix - Remove context merge hack and `did:` prefix removal hack from generator - Add cred:holder nodeKind fix (sh:Literal → sh:IRIOrLiteral) - Add credentialSubject, holder, verifiableCredential slots to w3c-vc.yaml - Add TransactionReceipt, HarbourPresentation, VerifiablePresentation types - Move trust-anchor-credential and delegated-signing-receipt to examples/gaiax/ - Remove root skeleton examples (legal/natural person) — all domain examples in gaiax/ - Keep core skeletons (credential-with-evidence) in root examples/ - Fix storyline DID consistency across all gaiax examples - Update gaiax example types from `harbour:` to `harbour_gx:` prefix - Add `harbour_gx:` context URL to all gaiax examples - Update claim_mapping.py: add HARBOUR_GX_NS, fix flat claim paths - Update conftest.py: add GAIAX_EXAMPLES_DIR, glob both root and gaiax examples - Update test_claim_mapping.py: remove dead skeleton tests, use MAPPINGS registry - Update test_example_signer.py: fix file paths and type assertions - Update test_validation.py: allow @vocab resolution, handle flat subject types - Update examples/README.md and gaiax/README.md with correct file paths - Point ontology-management-base submodule to fix/w3c-jsonld-contexts branch Signed-off-by: Carlo van Driesten --- examples/README.md | 80 +++---- ...ial.json => credential-with-evidence.json} | 28 ++- ...n => credential-with-nested-evidence.json} | 32 +-- examples/gaiax/README.md | 46 ++-- .../delegated-signing-receipt.json | 22 +- examples/gaiax/legal-person-credential.json | 80 ++++--- examples/gaiax/natural-person-credential.json | 52 +++-- .../{ => gaiax}/trust-anchor-credential.json | 6 +- linkml/harbour-core-credential.yaml | 177 +++++++++++----- linkml/harbour-gx-credential.yaml | 151 +++++++------ linkml/importmap.json | 70 ++++++ linkml/w3c-vc.yaml | 27 +++ src/python/credentials/claim_mapping.py | 50 +++-- src/python/harbour/generate_artifacts.py | 53 ++++- submodules/ontology-management-base | 2 +- tests/python/credentials/conftest.py | 36 +++- .../python/credentials/test_claim_mapping.py | 200 ++++-------------- .../python/credentials/test_example_signer.py | 40 ++-- tests/python/credentials/test_validation.py | 47 ++-- 19 files changed, 698 insertions(+), 501 deletions(-) rename examples/{legal-person-credential.json => credential-with-evidence.json} (68%) rename examples/{natural-person-credential.json => credential-with-nested-evidence.json} (57%) rename examples/{ => gaiax}/delegated-signing-receipt.json (81%) rename examples/{ => gaiax}/trust-anchor-credential.json (85%) create mode 100644 linkml/importmap.json diff --git a/examples/README.md b/examples/README.md index 4de28c2..78b05e2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -26,22 +26,22 @@ flowchart LR end ``` -## Skeleton Credentials vs Domain Extensions +## Core Credentials vs Gaia-X Domain -The examples in this directory are **harbour skeletons** — they define the -minimum base structure required for each credential type, without any -domain-specific compliance data. The `delegated-signing-receipt.json` is the -canonical reference for the skeleton pattern. +The examples use a **two-tier layout**: -Domain extensions live in subdirectories and add compliance data on top of the -skeleton. Currently: +- **Root (`examples/`)**: Core harbour credential skeletons that demonstrate the + envelope structure (evidence VP nesting, CRSet status) without domain-specific data. + Currently: `credential-with-evidence.json` and `credential-with-nested-evidence.json`. -- **`gaiax/`** — [Gaia-X domain extensions](gaiax/README.md): adds - `gxParticipant` inner nodes with Gaia-X properties (registration number, - addresses) and the `https://w3id.org/gaia-x/development#` context. +- **`gaiax/`**: [Gaia-X domain credentials](gaiax/README.md) — the complete + end-to-end user journey. These credentials carry Gaia-X properties + (registration number, addresses) and reference the + `https://w3id.org/gaia-x/development#` context. -The `gxParticipant` composition slot is defined as `required: false` in the -harbour schema — it is only populated when a domain extension needs it. +Credential types in the Gaia-X layer use the `harbour_gx:` namespace prefix +(e.g. `harbour_gx:LegalPersonCredential`, `harbour_gx:NaturalPerson`) while core +types use `harbour:` (e.g. `harbour:CRSetEntry`, `harbour:CredentialEvidence`). ## Credential Issuance Model @@ -61,7 +61,7 @@ contains a VP proving who authorized the issuance: The Trust Anchor holds a **self-signed LegalPersonCredential** (analogous to a root CA certificate) where `issuer == credentialSubject.id`. This credential is publicly resolvable via a `LinkedCredentialService` endpoint in the Trust -Anchor's DID document. See [`trust-anchor-credential.json`](trust-anchor-credential.json). +Anchor's DID document. See [`gaiax/trust-anchor-credential.json`](gaiax/trust-anchor-credential.json). ## Actors and Identities @@ -126,11 +126,11 @@ self-signed LegalPersonCredential, establishing the chain of trust. | File | Description | |------|-------------| -| [`trust-anchor-credential.json`](trust-anchor-credential.json) | Trust Anchor's self-signed credential (root of trust) | -| [`legal-person-credential.json`](legal-person-credential.json) | Unsigned credential (expanded JSON-LD) | -| [`signed/legal-person-credential.jwt`](signed/legal-person-credential.jwt) | Signed credential (VC-JOSE-COSE wire format) | -| [`signed/legal-person-credential.decoded.json`](signed/legal-person-credential.decoded.json) | Decoded JWT (header + payload) | -| [`signed/legal-person-credential.evidence-vp.jwt`](signed/legal-person-credential.evidence-vp.jwt) | Evidence VP (Trust Anchor authorization) | +| [`gaiax/trust-anchor-credential.json`](gaiax/trust-anchor-credential.json) | Trust Anchor's self-signed credential (root of trust) | +| [`gaiax/legal-person-credential.json`](gaiax/legal-person-credential.json) | Unsigned credential (expanded JSON-LD) | +| [`gaiax/signed/legal-person-credential.jwt`](gaiax/signed/legal-person-credential.jwt) | Signed credential (VC-JOSE-COSE wire format) | +| [`gaiax/signed/legal-person-credential.decoded.json`](gaiax/signed/legal-person-credential.decoded.json) | Decoded JWT (header + payload) | +| [`gaiax/signed/legal-person-credential.evidence-vp.jwt`](gaiax/signed/legal-person-credential.evidence-vp.jwt) | Evidence VP (Trust Anchor authorization) | | [`did-ethr/legal-person-0aa6d7ea-...did.json`](did-ethr/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json) | Legal person DID document | ### Code @@ -186,10 +186,10 @@ organizational affiliation without the credential itself leaking PII. | File | Description | |------|-------------| -| [`natural-person-credential.json`](natural-person-credential.json) | Unsigned credential (expanded JSON-LD) | -| [`signed/natural-person-credential.jwt`](signed/natural-person-credential.jwt) | Signed credential (VC-JOSE-COSE wire format) | -| [`signed/natural-person-credential.decoded.json`](signed/natural-person-credential.decoded.json) | Decoded JWT (header + payload) | -| [`signed/natural-person-credential.evidence-vp.jwt`](signed/natural-person-credential.evidence-vp.jwt) | Evidence VP (org authorization) | +| [`gaiax/natural-person-credential.json`](gaiax/natural-person-credential.json) | Unsigned credential (expanded JSON-LD) | +| [`gaiax/signed/natural-person-credential.jwt`](gaiax/signed/natural-person-credential.jwt) | Signed credential (VC-JOSE-COSE wire format) | +| [`gaiax/signed/natural-person-credential.decoded.json`](gaiax/signed/natural-person-credential.decoded.json) | Decoded JWT (header + payload) | +| [`gaiax/signed/natural-person-credential.evidence-vp.jwt`](gaiax/signed/natural-person-credential.evidence-vp.jwt) | Evidence VP (org authorization) | | [`did-ethr/natural-person-550e8400-...did.json`](did-ethr/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json) | Alice's DID document | ### Code @@ -197,7 +197,7 @@ organizational affiliation without the credential itself leaking PII. ```python # Python — convert to SD-JWT-VC flat claims from credentials.claim_mapping import vc_to_sd_jwt_claims, MAPPINGS -mapping = MAPPINGS["harbour:NaturalPersonCredential"] +mapping = MAPPINGS["harbour_gx:NaturalPersonCredential"] claims, disclosable = vc_to_sd_jwt_claims(credential, mapping) # claims: {"iss": ..., "vct": ..., "givenName": "Alice", "memberOf": "did:ethr:0x14a34:0x..."} # disclosable: ["givenName", "familyName", "email", "memberOf"] @@ -251,7 +251,7 @@ On the wire, the consent VP is an SD-JWT compact serialization: The consent VP is not persisted as a standalone example — it is an ephemeral artifact between Alice's wallet and the Signing Service. The receipt credential -([`delegated-signing-receipt.json`](delegated-signing-receipt.json)) embeds the +([`gaiax/delegated-signing-receipt.json`](gaiax/delegated-signing-receipt.json)) embeds the consent VP as evidence, making it the durable audit record. ### Code @@ -336,10 +336,10 @@ layers of information: | File | Description | |------|-------------| -| [`delegated-signing-receipt.json`](delegated-signing-receipt.json) | Unsigned receipt (expanded JSON-LD) | -| [`signed/delegated-signing-receipt.jwt`](signed/delegated-signing-receipt.jwt) | Signed receipt (VC-JOSE-COSE wire format) | -| [`signed/delegated-signing-receipt.decoded.json`](signed/delegated-signing-receipt.decoded.json) | Decoded JWT (header + payload) | -| [`signed/delegated-signing-receipt.evidence-vp.jwt`](signed/delegated-signing-receipt.evidence-vp.jwt) | Evidence VP (consent proof, signed) | +| [`gaiax/delegated-signing-receipt.json`](gaiax/delegated-signing-receipt.json) | Unsigned receipt (expanded JSON-LD) | +| [`gaiax/signed/delegated-signing-receipt.jwt`](gaiax/signed/delegated-signing-receipt.jwt) | Signed receipt (VC-JOSE-COSE wire format) | +| [`gaiax/signed/delegated-signing-receipt.decoded.json`](gaiax/signed/delegated-signing-receipt.decoded.json) | Decoded JWT (header + payload) | +| [`gaiax/signed/delegated-signing-receipt.evidence-vp.jwt`](gaiax/signed/delegated-signing-receipt.evidence-vp.jwt) | Evidence VP (consent proof, signed) | ### Code @@ -369,21 +369,21 @@ import { verifySdJwtVp, signJwt } from '@reachhaven/harbour-credentials'; ## File Index -### Harbour skeletons (unsigned, expanded JSON-LD) +### Core skeletons (unsigned, expanded JSON-LD) -| File | Step | Description | -|------|------|-------------| -| [`trust-anchor-credential.json`](trust-anchor-credential.json) | — | Trust Anchor self-signed credential (root of trust) | -| [`legal-person-credential.json`](legal-person-credential.json) | 1 | Organization credential (harbour skeleton) | -| [`natural-person-credential.json`](natural-person-credential.json) | 2 | Employee credential with `memberOf` link (harbour skeleton) | -| [`delegated-signing-receipt.json`](delegated-signing-receipt.json) | 3+4 | Transaction receipt with embedded consent VP as evidence | +| File | Description | +|------|-------------| +| [`credential-with-evidence.json`](credential-with-evidence.json) | Generic VC with evidence VP | +| [`credential-with-nested-evidence.json`](credential-with-nested-evidence.json) | Generic VC with nested evidence chain | -### Gaia-X domain extensions (`gaiax/`) +### Gaia-X domain storyline (`gaiax/`) -| File | Derives from | What's added | -|------|-------------|--------------| -| [`gaiax/legal-person-credential.json`](gaiax/legal-person-credential.json) | `legal-person-credential.json` | `gxParticipant` with registration number, addresses | -| [`gaiax/natural-person-credential.json`](gaiax/natural-person-credential.json) | `natural-person-credential.json` | `gxParticipant` with `gx:Participant` | +| File | Step | Description | +|------|------|-------------| +| [`gaiax/trust-anchor-credential.json`](gaiax/trust-anchor-credential.json) | — | Trust Anchor self-signed credential (root of trust) | +| [`gaiax/legal-person-credential.json`](gaiax/legal-person-credential.json) | 1 | Organization credential | +| [`gaiax/natural-person-credential.json`](gaiax/natural-person-credential.json) | 2 | Employee credential with `memberOf` link | +| [`gaiax/delegated-signing-receipt.json`](gaiax/delegated-signing-receipt.json) | 3+4 | Transaction receipt with embedded consent VP as evidence | ### Signed artifacts (`signed/`) diff --git a/examples/legal-person-credential.json b/examples/credential-with-evidence.json similarity index 68% rename from examples/legal-person-credential.json rename to examples/credential-with-evidence.json index c16b62b..c206dec 100644 --- a/examples/legal-person-credential.json +++ b/examples/credential-with-evidence.json @@ -5,15 +5,14 @@ ], "type": [ "VerifiableCredential", - "harbour:LegalPersonCredential" + "harbour:VerifiableCredential" ], - "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "id": "urn:uuid:11111111-1111-1111-1111-111111111111", "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "validFrom": "2024-01-15T00:00:00Z", "validUntil": "2025-01-15T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", - "type": "harbour:LegalPerson" + "id": "did:ethr:0x14a34:0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" }, "credentialStatus": [ { @@ -24,10 +23,18 @@ ], "evidence": [ { - "type": "harbour:CredentialEvidence", + "type": [ + "harbour:Evidence", + "harbour:CredentialEvidence" + ], "verifiablePresentation": { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiablePresentation"], + "@context": [ + "https://www.w3.org/ns/credentials/v2" + ], + "type": [ + "VerifiablePresentation", + "harbour:VerifiablePresentation" + ], "holder": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "verifiableCredential": [ { @@ -36,13 +43,12 @@ "https://w3id.org/reachhaven/harbour/credentials/v1/" ], "type": [ - "VerifiableCredential", - "harbour:LegalPersonCredential" + "VerifiableCredential" ], "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", - "type": "harbour:LegalPerson" + "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3" } } ] diff --git a/examples/natural-person-credential.json b/examples/credential-with-nested-evidence.json similarity index 57% rename from examples/natural-person-credential.json rename to examples/credential-with-nested-evidence.json index 4d7ab42..23742d5 100644 --- a/examples/natural-person-credential.json +++ b/examples/credential-with-nested-evidence.json @@ -5,16 +5,14 @@ ], "type": [ "VerifiableCredential", - "harbour:NaturalPersonCredential" + "harbour:VerifiableCredential" ], - "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f23456789012", + "id": "urn:uuid:22222222-2222-2222-2222-222222222222", "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "validFrom": "2024-01-15T00:00:00Z", "validUntil": "2025-01-15T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", - "type": "harbour:NaturalPerson", - "memberOf": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" + "id": "did:ethr:0x14a34:0xb2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2" }, "credentialStatus": [ { @@ -25,11 +23,19 @@ ], "evidence": [ { - "type": "harbour:CredentialEvidence", + "type": [ + "harbour:Evidence", + "harbour:CredentialEvidence" + ], "verifiablePresentation": { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiablePresentation"], - "holder": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", + "@context": [ + "https://www.w3.org/ns/credentials/v2" + ], + "type": [ + "VerifiablePresentation", + "harbour:VerifiablePresentation" + ], + "holder": "did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "verifiableCredential": [ { "@context": [ @@ -37,14 +43,12 @@ "https://w3id.org/reachhaven/harbour/credentials/v1/" ], "type": [ - "VerifiableCredential", - "harbour:LegalPersonCredential" + "VerifiableCredential" ], "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", - "type": "harbour:LegalPerson", - "name": "Example Corporation GmbH" + "id": "did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" } } ] diff --git a/examples/gaiax/README.md b/examples/gaiax/README.md index 21f25fd..4cb11c6 100644 --- a/examples/gaiax/README.md +++ b/examples/gaiax/README.md @@ -1,42 +1,35 @@ -# Gaia-X Domain Extensions +# Gaia-X Domain Credentials -This directory contains **Gaia-X domain extensions** of the harbour credential -skeletons in the parent `examples/` directory. Each file adds Gaia-X compliance -data to the base harbour skeleton using the **composition pattern**. +This directory contains the **complete Gaia-X credential storyline** — the +end-to-end user journey from trust anchor through organization and employee +onboarding to delegated blockchain transactions. -## Composition Pattern +## Structure -Harbour credentials use a two-layer model: +Credentials use the `harbour_gx:` namespace prefix +(`https://w3id.org/reachhaven/harbour/gaiax-domain/v1/`) for domain types +and properties, while core envelope types use `harbour:`. -1. **Outer node** (`harbour:LegalPerson` / `harbour:NaturalPerson`) — harbour-owned - properties (`name`, `memberOf`, CRSet status). -2. **Inner node** (`gxParticipant`) — a Gaia-X typed blank node - (`gx:LegalPerson`, `gx:Participant`) carrying Gaia-X properties - (`gx:registrationNumber`, `gx:headquartersAddress`, etc.). +| File | Step | Description | +|------|------|-------------| +| `trust-anchor-credential.json` | — | Trust Anchor self-signed credential (root of trust) | +| `legal-person-credential.json` | 1 | Organization credential with registration data | +| `natural-person-credential.json` | 2 | Employee credential with identity and `memberOf` link | +| `delegated-signing-receipt.json` | 3+4 | Transaction receipt with embedded consent VP as evidence | -This keeps harbour and Gaia-X SHACL shapes validating independently. The -`gxParticipant` slot is defined as `required: false` in the harbour schema — it -is only populated when Gaia-X compliance is needed. +## Context Stack -## Skeleton to Extension Derivation - -| Skeleton (parent `examples/`) | Gaia-X extension (this directory) | What's added | -|-------------------------------|-----------------------------------|--------------| -| `legal-person-credential.json` | `legal-person-credential.json` | `gxParticipant` with `gx:LegalPerson`, registration number, addresses | -| `natural-person-credential.json` | `natural-person-credential.json` | `gxParticipant` with `gx:Participant` | - -The `@context` array in Gaia-X extensions includes the Gaia-X namespace: +All credentials use a stacked `@context` array: ```json "@context": [ "https://www.w3.org/ns/credentials/v2", "https://w3id.org/gaia-x/development#", - "https://w3id.org/reachhaven/harbour/credentials/v1/" + "https://w3id.org/reachhaven/harbour/credentials/v1/", + "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/" ] ``` -Base skeletons omit this context entry entirely. - ## Regenerating Signed Artifacts ```bash @@ -46,3 +39,6 @@ PYTHONPATH=src/python:$PYTHONPATH python -m credentials.example_signer examples/ This processes both `examples/*.json` and `examples/gaiax/*.json`, producing signed artifacts in `examples/signed/` and `examples/gaiax/signed/` respectively. + +See the parent [`examples/README.md`](../README.md) for the full user journey +with sequence diagrams. diff --git a/examples/delegated-signing-receipt.json b/examples/gaiax/delegated-signing-receipt.json similarity index 81% rename from examples/delegated-signing-receipt.json rename to examples/gaiax/delegated-signing-receipt.json index ef634dc..db65e95 100644 --- a/examples/delegated-signing-receipt.json +++ b/examples/gaiax/delegated-signing-receipt.json @@ -1,7 +1,8 @@ { "@context": [ "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/reachhaven/harbour/credentials/v1/" + "https://w3id.org/reachhaven/harbour/credentials/v1/", + "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/" ], "type": [ "VerifiableCredential", @@ -11,7 +12,7 @@ "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "validFrom": "2025-06-25T10:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", + "id": "urn:uuid:receipt-b7c8d9e0-f1a2-3456-789a-bcdef0123456", "type": "harbour:TransactionReceipt", "transactionHash": "cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52", "blockchainTxId": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" @@ -25,29 +26,34 @@ ], "evidence": [ { - "type": "harbour:DelegatedSignatureEvidence", + "type": [ + "harbour:Evidence", + "harbour:DelegatedSignatureEvidence" + ], "verifiablePresentation": { "@context": [ "https://www.w3.org/ns/credentials/v2" ], "type": [ - "VerifiablePresentation" + "VerifiablePresentation", + "harbour:VerifiablePresentation" ], "holder": "did:jwk:eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6IjZka1U2Wk1GSzc5V3dpY3dKNXJieEUxM3pTdWtCWTJPb0VpVlVFanFNRWMiLCJ5IjoiUm5Iem55VmxyUFNNVDdpckRzMTVEOXd4Z01vamlTREFRcGZGaHFUa0xSWSJ9", "verifiableCredential": [ { "@context": [ "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/reachhaven/harbour/credentials/v1/" + "https://w3id.org/reachhaven/harbour/credentials/v1/", + "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/" ], "type": [ - "VerifiableCredential", - "harbour:NaturalPersonCredential" + "VerifiableCredential" ], "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "validFrom": "2024-01-15T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", - "type": "harbour:NaturalPerson", + "type": "harbour_gx:NaturalPerson", "memberOf": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" } } diff --git a/examples/gaiax/legal-person-credential.json b/examples/gaiax/legal-person-credential.json index 32a3c95..1d20bf8 100644 --- a/examples/gaiax/legal-person-credential.json +++ b/examples/gaiax/legal-person-credential.json @@ -2,11 +2,12 @@ "@context": [ "https://www.w3.org/ns/credentials/v2", "https://w3id.org/gaia-x/development#", - "https://w3id.org/reachhaven/harbour/credentials/v1/" + "https://w3id.org/reachhaven/harbour/credentials/v1/", + "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/" ], "type": [ "VerifiableCredential", - "harbour:LegalPersonCredential" + "harbour_gx:LegalPersonCredential" ], "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", @@ -14,26 +15,23 @@ "validUntil": "2025-01-15T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", - "type": "harbour:LegalPerson", - "gxParticipant": { - "type": "gx:LegalPerson", - "name": "Example Corporation GmbH", - "gx:registrationNumber": { - "type": "gx:RegistrationNumber", - "schema:taxID": "DE123456789" - }, - "gx:headquartersAddress": { - "type": "gx:Address", - "gx:countryCode": "DE", - "gx:countryName": "Germany", - "vcard:locality": "Munich" - }, - "gx:legalAddress": { - "type": "gx:Address", - "gx:countryCode": "DE", - "gx:countryName": "Germany", - "vcard:locality": "Munich" - } + "type": "harbour_gx:LegalPerson", + "name": "Example Corporation GmbH", + "registrationNumber": { + "type": "gx:RegistrationNumber", + "taxID": "DE123456789" + }, + "headquartersAddress": { + "type": "gx:Address", + "countryCode": "DE", + "countryName": "Germany", + "locality": "Munich" + }, + "legalAddress": { + "type": "gx:Address", + "countryCode": "DE", + "countryName": "Germany", + "locality": "Munich" } }, "credentialStatus": [ @@ -45,29 +43,47 @@ ], "evidence": [ { - "type": "harbour:CredentialEvidence", + "type": [ + "harbour:Evidence", + "harbour:CredentialEvidence" + ], "verifiablePresentation": { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiablePresentation"], + "@context": [ + "https://www.w3.org/ns/credentials/v2" + ], + "type": [ + "VerifiablePresentation", + "harbour:VerifiablePresentation" + ], "holder": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "verifiableCredential": [ { "@context": [ "https://www.w3.org/ns/credentials/v2", "https://w3id.org/gaia-x/development#", - "https://w3id.org/reachhaven/harbour/credentials/v1/" + "https://w3id.org/reachhaven/harbour/credentials/v1/", + "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/" ], "type": [ - "VerifiableCredential", - "harbour:LegalPersonCredential" + "VerifiableCredential" ], "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", - "type": "harbour:LegalPerson", - "gxParticipant": { - "type": "gx:LegalPerson", - "name": "ReachHaven GmbH" + "type": "harbour_gx:LegalPerson", + "name": "ReachHaven GmbH", + "registrationNumber": { + "type": "gx:RegistrationNumber", + "taxID": "DE987654321" + }, + "headquartersAddress": { + "type": "gx:Address", + "countryCode": "DE" + }, + "legalAddress": { + "type": "gx:Address", + "countryCode": "DE" } } } diff --git a/examples/gaiax/natural-person-credential.json b/examples/gaiax/natural-person-credential.json index 6663519..0a2814b 100644 --- a/examples/gaiax/natural-person-credential.json +++ b/examples/gaiax/natural-person-credential.json @@ -2,11 +2,12 @@ "@context": [ "https://www.w3.org/ns/credentials/v2", "https://w3id.org/gaia-x/development#", - "https://w3id.org/reachhaven/harbour/credentials/v1/" + "https://w3id.org/reachhaven/harbour/credentials/v1/", + "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/" ], "type": [ "VerifiableCredential", - "harbour:NaturalPersonCredential" + "harbour_gx:NaturalPersonCredential" ], "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f23456789012", "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", @@ -14,14 +15,17 @@ "validUntil": "2025-01-15T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", - "type": "harbour:NaturalPerson", - "gxParticipant": { - "type": "gx:NaturalPerson", - "givenName": "Alice", - "familyName": "Smith", - "email": "alice.smith@example.com" - }, - "memberOf": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" + "type": "harbour_gx:NaturalPerson", + "givenName": "Alice", + "familyName": "Smith", + "email": "alice.smith@example.com", + "memberOf": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", + "address": { + "type": "gx:Address", + "countryCode": "DE", + "countryName": "Germany", + "locality": "Munich" + } }, "credentialStatus": [ { @@ -32,30 +36,36 @@ ], "evidence": [ { - "type": "harbour:CredentialEvidence", + "type": [ + "harbour:Evidence", + "harbour:CredentialEvidence" + ], "verifiablePresentation": { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiablePresentation"], + "@context": [ + "https://www.w3.org/ns/credentials/v2" + ], + "type": [ + "VerifiablePresentation", + "harbour:VerifiablePresentation" + ], "holder": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", "verifiableCredential": [ { "@context": [ "https://www.w3.org/ns/credentials/v2", "https://w3id.org/gaia-x/development#", - "https://w3id.org/reachhaven/harbour/credentials/v1/" + "https://w3id.org/reachhaven/harbour/credentials/v1/", + "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/" ], "type": [ - "VerifiableCredential", - "harbour:LegalPersonCredential" + "VerifiableCredential" ], "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", - "type": "harbour:LegalPerson", - "gxParticipant": { - "type": "gx:LegalPerson", - "name": "Example Corporation GmbH" - } + "type": "harbour_gx:LegalPerson", + "name": "Example Corporation GmbH" } } ] diff --git a/examples/trust-anchor-credential.json b/examples/gaiax/trust-anchor-credential.json similarity index 85% rename from examples/trust-anchor-credential.json rename to examples/gaiax/trust-anchor-credential.json index 79f86b2..9953b33 100644 --- a/examples/trust-anchor-credential.json +++ b/examples/gaiax/trust-anchor-credential.json @@ -5,15 +5,13 @@ ], "type": [ "VerifiableCredential", - "harbour:LegalPersonCredential" + "harbour:VerifiableCredential" ], "id": "urn:uuid:c4d5e6f7-a8b9-0123-cdef-456789abcdef", "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", - "type": "harbour:LegalPerson", - "name": "ReachHaven GmbH" + "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3" }, "credentialStatus": [ { diff --git a/linkml/harbour-core-credential.yaml b/linkml/harbour-core-credential.yaml index 4f7270a..8310289 100644 --- a/linkml/harbour-core-credential.yaml +++ b/linkml/harbour-core-credential.yaml @@ -52,8 +52,7 @@ prefixes: xsd: http://www.w3.org/2001/XMLSchema# cs: https://www.w3.org/ns/credentials/status# cred: https://www.w3.org/2018/credentials# - schema: http://schema.org/ - did: https://www.w3.org/ns/did# + didcore: https://www.w3.org/ns/did# default_prefix: harbour default_range: string @@ -88,16 +87,27 @@ slots: # entity authorized to make changes to the DID document. # In practice always a DID (did:ethr:..., did:key:..., etc.). controller: - slot_uri: did:controller + slot_uri: didcore:controller range: uri # [DID-CORE] §5.4 — serviceEndpoint can be a URI, map, or set. # Each service entry MUST have id, type, and serviceEndpoint. serviceEndpoint: - slot_uri: did:serviceEndpoint + slot_uri: didcore:serviceEndpoint range: Any required: true + # --- Delegated Signing Evidence Slots --- + # [OID4VP] §B.1.3.2.5 — Data Integrity proof.challenge = OID4VP nonce. + # Harbour Delegation Spec §3 — challenge = " HARBOUR_DELEGATE ". + challenge: + description: > + OID4VP-aligned challenge string binding the delegated transaction + to a specific nonce and content hash. + slot_uri: harbour:challenge + range: string + required: false + # --- Revocation Registry (CRSet) Slots --- # [VCDM2] §4.10 — credentialStatus MUST have id and type; statusPurpose # defined by BitstringStatusList. CRSet is the Harbour-specific mechanism. @@ -133,39 +143,13 @@ slots: required: false # --- Trust Anchor / Organization Slots --- - # [SCHEMA-ORG] — https://schema.org/ContactPoint + # NOTE: name, description, url, email, contactPoint are NOT defined here. + # When the gaia-x import is active (harbour-gx-credential.yaml), gx + # provides schema:name and schema:description as shared slots. + # url, email, contactPoint use different gx URIs, so they are defined + # as inline attributes on OrganizationEndpoint / ContactPoint below. contactType: - slot_uri: schema:contactType - range: string - - # [SCHEMA-ORG] — https://schema.org/name - name: - description: A human-readable name for the entity. - slot_uri: schema:name - range: string - - # [SCHEMA-ORG] — https://schema.org/description - description: - description: A human-readable description of the entity. - slot_uri: schema:description - range: string - - # [SCHEMA-ORG] — https://schema.org/url - url: - description: A URL associated with the entity. - slot_uri: schema:url - range: uri - - # [SCHEMA-ORG] — https://schema.org/email - email: - description: An email address. - slot_uri: schema:email - range: string - - # [SCHEMA-ORG] — https://schema.org/contactPoint - contactPoint: - description: A contact point for the entity. - slot_uri: schema:contactPoint + slot_uri: https://schema.org/contactType range: string # [VC-JOSE-COSE] §6.1 — media types: application/vp+jwt, application/vp+sd-jwt. @@ -177,7 +161,7 @@ slots: shown as expanded JSON-LD for readability; on the wire it is encoded as a VC-JOSE-COSE compact JWS string (typ: vp+jwt) or SD-JWT VP. slot_uri: harbour:verifiablePresentation - range: Any + range: VerifiablePresentation required: false # --- Delegated Signature Evidence Slots --- @@ -218,14 +202,14 @@ classes: # [DID-CORE] §4 — DID Document is a set of data describing the DID subject. # Properties: id (REQUIRED), controller, verificationMethod, service, etc. DIDDocument: - class_uri: did:DIDDocument + class_uri: didcore:DIDDocument slots: - controller attributes: # [DID-CORE] §5.4 — service is OPTIONAL, each entry MUST have id, type, # and serviceEndpoint. service values MUST be unique. service: - slot_uri: did:service + slot_uri: didcore:service multivalued: true inlined: true range: ServiceUnion @@ -233,7 +217,7 @@ classes: # controller, and key material (publicKeyJwk or publicKeyMultibase). # Harbour models a subset (id, controller, blockchainAccountId). verificationMethod: - slot_uri: did:verificationMethod + slot_uri: didcore:verificationMethod multivalued: true range: VerificationMethod @@ -274,22 +258,35 @@ classes: # Uses schema.org Organization vocabulary for interoperability. OrganizationEndpoint: - class_uri: schema:Organization - slots: - - name - - url - - description - - contactPoint - slot_usage: + class_uri: https://schema.org/Organization + attributes: + name: + description: A human-readable name for the organization. + slot_uri: https://schema.org/name + range: string + url: + description: A URL associated with the organization. + slot_uri: https://schema.org/url + range: uri + description: + description: A human-readable description of the organization. + slot_uri: https://schema.org/description + range: string contactPoint: + description: A contact point for the organization. + slot_uri: https://schema.org/contactPoint range: ContactPoint inlined: true ContactPoint: - class_uri: schema:ContactPoint + class_uri: https://schema.org/ContactPoint slots: - contactType - - email + attributes: + email: + description: An email address. + slot_uri: https://schema.org/email + range: string # Harbour-specific: CRSet revocation registry service endpoint. # [VCDM2] §4.10 — credentialStatus mechanisms are extensible. CRSet is a @@ -334,6 +331,7 @@ classes: - issuer - validFrom - validUntil + - credentialSubject - evidence - credentialStatus slot_usage: @@ -349,6 +347,9 @@ classes: SD-JWT-VC maps to iat (OPTIONAL) or nbf (OPTIONAL). validUntil: required: false + credentialSubject: + range: Any + required: false evidence: range: Evidence required: false @@ -358,6 +359,57 @@ classes: required: true description: Status entries for revocation checking (CRSet). + VerifiableCredential: + is_a: HarbourCredential + description: > + Concrete credential type at the core layer. Validates only the + Harbour envelope constraints (issuer, validFrom, credentialStatus). + No domain-specific credentialSubject requirements — any subject is + valid. Domain layers (e.g. harbour-gx-credential) define specialized + credential types with typed subjects. + class_uri: harbour:VerifiableCredential + annotations: + vct: "https://w3id.org/reachhaven/harbour/credentials/v1/VerifiableCredential" + + # ========================================== + # 3b. PRESENTATION TYPES + # ========================================== + # [VCDM2] §6 — A verifiable presentation is a tamper-evident presentation + # of one or more verifiable credentials. Contains holder, type, and + # verifiableCredential. Secured via VC-JOSE-COSE (vp+jwt) or SD-JWT VP. + + HarbourPresentation: + abstract: true + description: > + Abstract base for all Harbour presentations. Requires holder and + at least one verifiableCredential. On the wire, presentations are + encoded as VC-JOSE-COSE compact JWS (typ: vp+jwt) or SD-JWT VP. + class_uri: harbour:HarbourPresentation + slots: + - holder + - verifiableCredential + slot_usage: + holder: + required: true + description: DID of the entity presenting the credentials. + verifiableCredential: + range: Any + required: true + description: > + One or more verifiable credentials being presented. On the wire + each entry is a compact JWS string (vc+jwt) or SD-JWT-VC token. + + VerifiablePresentation: + is_a: HarbourPresentation + description: > + Concrete presentation type at the core layer. Wraps one or more + harbour:VerifiableCredential instances for transmission. Evidence + VPs embedded in credentials use this type. Domain layers may define + specialized presentation types if needed. + class_uri: harbour:VerifiablePresentation + annotations: + vpt: "https://w3id.org/reachhaven/harbour/credentials/v1/VerifiablePresentation" + # ========================================== # 4. EVIDENCE TYPES # ========================================== @@ -411,6 +463,7 @@ classes: - verifiablePresentation - delegatedTo - transaction_data + - challenge slot_usage: verifiablePresentation: required: true @@ -418,6 +471,8 @@ classes: required: true transaction_data: required: true + challenge: + required: true # [VCDM2] §4.10 — credentialStatus entries MUST have id and type. # CRSet is a Harbour-defined status type (not BitstringStatusListEntry). @@ -427,6 +482,28 @@ classes: slots: - statusPurpose + # ========================================== + # 5. CREDENTIAL SUBJECT TYPES + # ========================================== + # Typed credentialSubject classes for domain-specific credentials. + # Domain layers (gx, simpulseid) define participant-based subjects; + # core defines receipt/transaction subjects. + + TransactionReceipt: + description: > + Credential subject for a delegated signing receipt. + Records the blockchain transaction hash and ID for audit. + class_uri: harbour:TransactionReceipt + attributes: + transactionHash: + description: SHA-256 hash of the canonical transaction data. + slot_uri: harbour:transactionHash + range: string + blockchainTxId: + description: On-chain transaction identifier. + slot_uri: harbour:blockchainTxId + range: string + # ========================================== # 6. HELPERS # ========================================== @@ -436,7 +513,7 @@ classes: # [VC-JOSE-COSE] §4.2 — verification method type MUST be JsonWebKey; key # material MUST be in publicKeyJwk property. VerificationMethod: - class_uri: did:VerificationMethod + class_uri: didcore:VerificationMethod slots: - controller attributes: diff --git a/linkml/harbour-gx-credential.yaml b/linkml/harbour-gx-credential.yaml index dba0ea5..980779e 100644 --- a/linkml/harbour-gx-credential.yaml +++ b/linkml/harbour-gx-credential.yaml @@ -2,10 +2,11 @@ id: https://w3id.org/reachhaven/harbour/gaiax-domain/v1 name: harbour-gx-credential description: > Gaia-X domain layer for Harbour credentials. - Defines participant types that wrap Gaia-X compliance data via - composition. Harbour outer nodes carry harbour-specific properties; - nested gx blank nodes carry only gx properties, keeping gx closed - SHACL shapes intact. + Defines participant types via inheritance from gx: base classes. + harbour:LegalPerson extends gx:LegalPerson (inherits registrationNumber, + legalAddress, headquartersAddress). harbour:NaturalPerson extends + gx:Participant directly — Gaia-X has no NaturalPerson, so Harbour + defines one as a sibling of gx:LegalPerson in the gx type hierarchy. # ============================================================================ # SPECIFICATION REFERENCES @@ -23,50 +24,58 @@ description: > # # DESIGN DECISIONS # ============================================================================ -# Composition over extension: Gaia-X Trust Framework defines closed SHACL -# shapes (sh:closed true) on gx: types. Adding ANY property to a gx: node -# violates the closed shape. Therefore Harbour uses composition — the -# harbour outer node owns harbour-specific properties, and a nested gx -# blank node carries only gx-valid properties. -# See: docs/specs/references/gx-architecture-document-25.11.md +# Inheritance over composition: Harbour participant types extend gx: base +# classes directly via is_a, placing them in the gx type hierarchy: # -# gxParticipant range is Any: The gx blank node content is validated by -# Gaia-X's own SHACL shapes (gx.shacl.ttl), not harbour's. Harbour SHACL -# is generated with exclude_imports=True to keep shapes separate. +# gx:Participant (abstract) +# ├─ gx:LegalPerson +# │ └─ harbour:LegalPerson (is_a: LegalPerson) +# └─ harbour:NaturalPerson (is_a: Participant) +# +# harbour:LegalPerson inherits registrationNumber, legalAddress, +# headquartersAddress from gx:LegalPerson. +# harbour:NaturalPerson inherits name, description from gx:Participant +# and adds person-specific attributes (givenName, familyName, address). +# +# Gaia-X has no NaturalPerson — Harbour defines one as a direct subclass +# of gx:Participant, parallel to gx:LegalPerson. +# +# Harbour SHACL is generated with exclude_imports=True to avoid +# duplicating gx shapes. gx shapes are validated separately. # ============================================================================ prefixes: linkml: https://w3id.org/linkml/ harbour: https://w3id.org/reachhaven/harbour/credentials/v1/ + harbour_gx: https://w3id.org/reachhaven/harbour/gaiax-domain/v1/ xsd: http://www.w3.org/2001/XMLSchema# cred: https://www.w3.org/2018/credentials# - schema: http://schema.org/ + gx: https://w3id.org/gaia-x/development# -default_prefix: harbour +default_prefix: harbour_gx default_range: string imports: - linkml:types - ./harbour-core-credential + - gaia-x # ========================================== -# Composition Slots +# Composition Slot (used by simpulseid layer) # ========================================== -# [GX-AD] — Gaia-X Trust Framework 25.11 defines closed SHACL shapes on -# gx:LegalPerson, gx:Participant. These shapes require specific properties -# (registrationNumber, headquartersAddress, legalAddress). -# [GX-SHACL] — gx:LegalPersonShape has sh:closed true (see gx.shacl.ttl). -# Adding properties to gx nodes violates the closed shape constraint. -# Solution: composition — harbour outer node owns harbour properties; -# nested gx blank node carries only gx properties. +# The gxParticipant slot allows simpulseid credential subjects to +# reference a harbour participant (LegalPerson or NaturalPerson) +# as a nested node. Typed as gx:Participant — the abstract base +# for all participant types in the gx hierarchy. slots: gxParticipant: description: > - Nested Gaia-X participant compliance data - (gx:LegalPerson or gx:NaturalPerson blank node). - slot_uri: harbour:gxParticipant - range: Any + Reference to a Gaia-X participant node (harbour:LegalPerson or + harbour:NaturalPerson). Used by downstream credential layers + (e.g. simpulseid) to embed participant data in credential subjects. + slot_uri: harbour_gx:gxParticipant + range: Participant required: false @@ -86,14 +95,14 @@ classes: is_a: HarbourCredential description: > Credential attesting to a harbour:LegalPerson (organization) identity. - In skeleton form the credentialSubject carries only harbour properties - (name). When Gaia-X compliance is needed, gxParticipant carries the - gx:LegalPerson blank node with compliance data. - class_uri: harbour:LegalPersonCredential + The credentialSubject is a harbour:LegalPerson which extends + gx:LegalPerson — inheriting registrationNumber, legalAddress, + headquartersAddress directly. + class_uri: harbour_gx:LegalPersonCredential annotations: # [SD-JWT-VC] §3.2.2.1 — vct MUST be a case-sensitive StringOrURI # identifying the credential type. - vct: "https://w3id.org/reachhaven/harbour/credentials/v1/LegalPersonCredential" + vct: "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/LegalPersonCredential" slot_usage: validFrom: required: true @@ -104,14 +113,13 @@ classes: is_a: HarbourCredential description: > Credential attesting to a harbour:NaturalPerson (individual) identity. - Personal attributes (givenName, familyName, email) live inside the - gxParticipant node as a gx:NaturalPerson blank node (harbour-defined - extension of gx:Participant). The outer harbour node carries only - memberOf (organization link) and the gxParticipant composition slot. - class_uri: harbour:NaturalPersonCredential + The credentialSubject is a harbour:NaturalPerson which extends + gx:Participant directly — inheriting name/description and adding + person-specific attributes (givenName, familyName, address, email). + class_uri: harbour_gx:NaturalPersonCredential annotations: # [SD-JWT-VC] §3.2.2.1 — vct MUST be a case-sensitive StringOrURI. - vct: "https://w3id.org/reachhaven/harbour/credentials/v1/NaturalPersonCredential" + vct: "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/NaturalPersonCredential" slot_usage: validFrom: required: true @@ -121,44 +129,51 @@ classes: # ========================================== # 2. PARTICIPANT TYPES # ========================================== - # Harbour wraps Gaia-X participant types via composition. - # Gaia-X data lives in nested blank nodes (gxParticipant) to keep - # gx closed shapes intact. - # [GX-SHACL] — gx:LegalPersonShape (sh:closed true) requires: - # gx:registrationNumber (≥1), gx:legalAddress (=1), - # gx:headquartersAddress (=1). Optional: schema:name (≤1), - # schema:description (≤1), gx:parentOrganizationOf, gx:subOrganisationOf. + # Harbour participant types extend gx: base classes directly. + # harbour:LegalPerson is_a gx:LegalPerson — inherits all gx compliance + # properties (registrationNumber, legalAddress, headquartersAddress). + # harbour:NaturalPerson is_a gx:Participant — Gaia-X has no NaturalPerson, + # so Harbour creates one as a sibling of gx:LegalPerson. - LegalPerson: + HarbourLegalPerson: + is_a: LegalPerson description: > - A legal person (organization) participating in the harbour ecosystem. - In skeleton form the outer node carries only the DID subject identifier. - When Gaia-X compliance is needed, schema:name and all required gx:LegalPerson - attributes live in the gxParticipant inner node (gx:LegalPerson blank node), - keeping the gx closed shape intact. - class_uri: harbour:LegalPerson - slots: - - name - - gxParticipant - slot_usage: - name: - required: false + A legal person (organization) in the harbour ecosystem. + Extends gx:LegalPerson — inherits registrationNumber, legalAddress, + headquartersAddress, name, description from the Gaia-X type hierarchy. + class_uri: harbour_gx:LegalPerson - NaturalPerson: + HarbourNaturalPerson: + is_a: Participant description: > - A natural person (individual) participating in the harbour ecosystem. - Personal attributes (givenName, familyName, email) live inside the - gxParticipant node as a gx:NaturalPerson blank node — a harbour-defined - extension of gx:Participant (which is sh:closed false). - The outer node carries only the organization link (memberOf) and the - composition slot (gxParticipant). - class_uri: harbour:NaturalPerson + A natural person (individual) in the harbour ecosystem. + Extends gx:Participant directly — Gaia-X defines no NaturalPerson, + so Harbour creates one as a sibling of gx:LegalPerson. + Inherits name, description from gx:Participant. Adds person-specific + attributes (givenName, familyName, address, email, memberOf). + class_uri: harbour_gx:NaturalPerson slots: - - gxParticipant + - address + - email + slot_usage: + email: + slot_uri: https://schema.org/email + address: + slot_uri: gx:address attributes: + # [SCHEMA-ORG] — https://schema.org/givenName + givenName: + description: First name / given name of the natural person. + slot_uri: https://schema.org/givenName + range: string + # [SCHEMA-ORG] — https://schema.org/familyName + familyName: + description: Last name / family name of the natural person. + slot_uri: https://schema.org/familyName + range: string # [SCHEMA-ORG] — https://schema.org/memberOf memberOf: description: Organization (LegalPerson) the natural person belongs to. - slot_uri: schema:memberOf + slot_uri: https://schema.org/memberOf range: uri diff --git a/linkml/importmap.json b/linkml/importmap.json new file mode 100644 index 0000000..2606bcf --- /dev/null +++ b/linkml/importmap.json @@ -0,0 +1,70 @@ +{ + "access-usage-policy": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/access-usage-policy", + "address": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/address", + "availability-zone": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/availability-zone", + "code-artifact": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/code-artifact", + "compliance": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/compliance", + "compute-function-configuration": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/compute-function-configuration", + "compute-function-service-offering": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/compute-function-service-offering", + "connectivity-configuration": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/connectivity-configuration", + "connectivity-service-offering": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/connectivity-service-offering", + "contact-information": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/contact-information", + "container-image": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/container-image", + "container-resource-limits": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/container-resource-limits", + "country-names": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/country-names", + "cpu": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/cpu", + "cryptographic-security-standards": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/cryptographic-security-standards", + "cryptography": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/cryptography", + "customer-instructions": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/customer-instructions", + "data-portability": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/data-portability", + "data-product": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/data-product", + "data-product-catalogue": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/data-product-catalogue", + "data-protection-policy": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/data-protection-policy", + "data-transfer": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/data-transfer", + "datacenter": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/datacenter", + "device": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/device", + "digital-service-offering": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/digital-service-offering", + "disk": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/disk", + "endpoint": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/endpoint", + "energy-mix": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/energy-mix", + "energy-usage-efficiency": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/energy-usage-efficiency", + "gaia-x": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/gaia-x", + "gaia-x-entity": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/gaia-x-entity", + "gpu": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/gpu", + "image": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/image", + "instantiation-requirement": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/instantiation-requirement", + "interconnection-service-offering": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/interconnection-service-offering", + "issuer": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/issuer", + "legal-document": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/legal-document", + "legal-person": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/legal-person", + "legitimate-interest": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/legitimate-interest", + "link-connectivity-service-offering": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/link-connectivity-service-offering", + "measure": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/measure", + "memory": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/memory", + "meta-registry": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/meta-registry", + "mime-types": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/mime-types", + "network-connectivity-service-offering": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/network-connectivity-service-offering", + "participant": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/participant", + "physical-connectivity-service-offering": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/physical-connectivity-service-offering", + "pxe-image": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/pxe-image", + "qos": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/qos", + "quantity": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/quantity", + "region": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/region", + "region-codes": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/region-codes", + "resource": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/resource", + "server-flavor": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/server-flavor", + "service-offering": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/service-offering", + "slots": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/slots", + "standard-conformity": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/standard-conformity", + "storage-configuration": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/storage-configuration", + "storage-service-offering": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/storage-service-offering", + "sub-contractor": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/sub-contractor", + "sub-processor-data-transfer": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/sub-processor-data-transfer", + "third-country-data-transfer": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/third-country-data-transfer", + "vm-image": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/vm-image", + "water-usage-effectiveness": "../submodules/ontology-management-base/submodules/service-characteristics/linkml/water-usage-effectiveness", + "./harbour-core-credential": "./harbour-core-credential", + "harbour-core-credential": "./harbour-core-credential", + "./w3c-vc": "./w3c-vc", + "w3c-vc": "./w3c-vc" +} diff --git a/linkml/w3c-vc.yaml b/linkml/w3c-vc.yaml index 813b47c..1dc1552 100644 --- a/linkml/w3c-vc.yaml +++ b/linkml/w3c-vc.yaml @@ -94,3 +94,30 @@ slots: description: > [VCDM2] §4.10 — credentialStatus is OPTIONAL (0..*), each MUST have id and type. + + # [VCDM2] §4.6 — A verifiable credential MUST contain a + # credentialSubject property. The value is a set of objects containing + # claims about the subject(s) of the credential. Each subject MAY + # have an id property. + credentialSubject: + slot_uri: cred:credentialSubject + multivalued: true + description: > + [VCDM2] §4.6 — credentialSubject is REQUIRED (1..*), each MAY have id. + + # [VCDM2] §6.1 — A verifiable presentation MUST have a holder property. + # The value MUST be a URL (typically a DID) identifying the entity + # presenting the credentials. + holder: + slot_uri: cred:holder + range: string + description: > + [VCDM2] §6.1 — holder MUST exist on a VP; value MUST be a URL (DID). + + # [VCDM2] §6.1 — verifiableCredential contains the VCs being presented. + # Each entry is a VerifiableCredential or an enveloped credential. + verifiableCredential: + slot_uri: cred:verifiableCredential + multivalued: true + description: > + [VCDM2] §6.1 — verifiableCredential is REQUIRED (1..*) on a VP. diff --git a/src/python/credentials/claim_mapping.py b/src/python/credentials/claim_mapping.py index c410792..9d5a9c5 100644 --- a/src/python/credentials/claim_mapping.py +++ b/src/python/credentials/claim_mapping.py @@ -25,6 +25,9 @@ # Harbour namespace HARBOUR_NS = "https://w3id.org/reachhaven/harbour/credentials/v1/" +# Harbour Gaia-X domain namespace +HARBOUR_GX_NS = "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/" + # Gaia-X namespace GAIAX_NS = "https://w3id.org/gaia-x/development#" @@ -33,18 +36,27 @@ # --------------------------------------------------------------------------- HARBOUR_LEGAL_PERSON_MAPPING = { - "vct": f"{HARBOUR_NS}LegalPersonCredential", - "claims": {}, - "always_disclosed": ["iss", "vct", "iat", "exp"], - "selectively_disclosed": [], + "vct": f"{HARBOUR_GX_NS}LegalPersonCredential", + "claims": { + "credentialSubject.name": "legalName", + "credentialSubject.registrationNumber": "registrationNumber", + "credentialSubject.headquartersAddress": "headquartersAddress", + "credentialSubject.legalAddress": "legalAddress", + }, + "always_disclosed": ["iss", "vct", "iat", "exp", "legalName"], + "selectively_disclosed": [ + "registrationNumber", + "headquartersAddress", + "legalAddress", + ], } HARBOUR_NATURAL_PERSON_MAPPING = { - "vct": f"{HARBOUR_NS}NaturalPersonCredential", + "vct": f"{HARBOUR_GX_NS}NaturalPersonCredential", "claims": { - "credentialSubject.gxParticipant.givenName": "givenName", - "credentialSubject.gxParticipant.familyName": "familyName", - "credentialSubject.gxParticipant.email": "email", + "credentialSubject.givenName": "givenName", + "credentialSubject.familyName": "familyName", + "credentialSubject.email": "email", "credentialSubject.memberOf": "memberOf", }, "always_disclosed": ["iss", "vct", "iat", "exp"], @@ -52,11 +64,12 @@ } # --------------------------------------------------------------------------- -# Gaia-X domain mappings (extends base with gxParticipant) +# Gaia-X domain mappings (with gxParticipant inner node) +# Used when the credential wraps gx data inside a gxParticipant nested object. # --------------------------------------------------------------------------- GAIAX_LEGAL_PERSON_MAPPING = { - "vct": f"{HARBOUR_NS}LegalPersonCredential", + "vct": f"{HARBOUR_GX_NS}LegalPersonCredential", "claims": { "credentialSubject.gxParticipant.name": "legalName", "credentialSubject.gxParticipant.gx:registrationNumber": "registrationNumber", @@ -72,7 +85,7 @@ } GAIAX_NATURAL_PERSON_MAPPING = { - "vct": f"{HARBOUR_NS}NaturalPersonCredential", + "vct": f"{HARBOUR_GX_NS}NaturalPersonCredential", "claims": { "credentialSubject.gxParticipant.givenName": "givenName", "credentialSubject.gxParticipant.familyName": "familyName", @@ -94,14 +107,14 @@ # Base harbour mappings (skeleton credentials) MAPPINGS: dict[str, dict] = { - "harbour:LegalPersonCredential": HARBOUR_LEGAL_PERSON_MAPPING, - "harbour:NaturalPersonCredential": HARBOUR_NATURAL_PERSON_MAPPING, + "harbour_gx:LegalPersonCredential": HARBOUR_LEGAL_PERSON_MAPPING, + "harbour_gx:NaturalPersonCredential": HARBOUR_NATURAL_PERSON_MAPPING, } # Gaia-X domain mappings (extended with gxParticipant) GAIAX_MAPPINGS: dict[str, dict] = { - "harbour:LegalPersonCredential": GAIAX_LEGAL_PERSON_MAPPING, - "harbour:NaturalPersonCredential": GAIAX_NATURAL_PERSON_MAPPING, + "harbour_gx:LegalPersonCredential": GAIAX_LEGAL_PERSON_MAPPING, + "harbour_gx:NaturalPersonCredential": GAIAX_NATURAL_PERSON_MAPPING, } @@ -243,10 +256,9 @@ def get_mapping_for_vc(vc: dict) -> dict | None: elif isinstance(at_type, list): vc_types = vc_types + at_type - # Choose registry based on context - registry = GAIAX_MAPPINGS if _has_gaiax_context(vc) else MAPPINGS - - for vc_type, mapping in registry.items(): + # Use primary registry — GAIAX_MAPPINGS is reserved for gxParticipant-nested + # patterns (not yet used in current examples). + for vc_type, mapping in MAPPINGS.items(): if vc_type in vc_types: return mapping return None diff --git a/src/python/harbour/generate_artifacts.py b/src/python/harbour/generate_artifacts.py index 49212e6..f0bdd5b 100644 --- a/src/python/harbour/generate_artifacts.py +++ b/src/python/harbour/generate_artifacts.py @@ -35,18 +35,26 @@ class HarbourShaclGenerator(_BaseShaclGenerator): - """SHACL generator that corrects IRI-valued property shapes. + """SHACL generator with importmap-aware initialisation and IRI fixes. - ``cred:issuer`` is defined as ``@type: @id`` in the W3C VC v2 context, - meaning JSON-LD processors expand issuer values to IRIs. LinkML has no - native IRI range type, so we patch the generated graph directly. + Bypasses ``ShaclGenerator.__post_init__``'s ``SchemaView`` construction + which ignores ``importmap`` / ``base_dir``, causing cross-directory + imports to fail. + See https://github.com/linkml/linkml/issues/2913 - Also removes ``sh:class linkml:Any`` constraints: LinkML emits these for - ``range: Any`` slots, but ``linkml:Any`` is a meta-schema type never - asserted as ``rdf:type`` on instance data. + Also corrects ``cred:issuer`` property shape (IRI, not Literal) and + removes ``sh:class linkml:Any`` constraints. See https://github.com/linkml/linkml/issues/2914 """ + uses_schemaloader = False + + def __post_init__(self) -> None: + from linkml.utils.generator import Generator + + Generator.__post_init__(self) + self.generate_header() + def as_graph(self): g = super().as_graph() # Fix cred:issuer nodeKind (IRI, not Literal) @@ -55,6 +63,12 @@ def as_graph(self): g.add((ps, SH.nodeKind, SH.IRIOrLiteral)) for dt in list(g.objects(ps, SH.datatype)): g.remove((ps, SH.datatype, dt)) + # Fix cred:holder nodeKind (IRI, not Literal) — DIDs are IRIs + for ps in g.subjects(SH.path, CRED.holder): + g.remove((ps, SH.nodeKind, SH.Literal)) + g.add((ps, SH.nodeKind, SH.IRIOrLiteral)) + for dt in list(g.objects(ps, SH.datatype)): + g.remove((ps, SH.datatype, dt)) # Remove sh:class linkml:Any — meta-type not present in instance data for s, p, o in list(g.triples((None, SH["class"], LINKML.Any))): g.remove((s, p, o)) @@ -81,24 +95,43 @@ def visit_slot(self, aliased_slot_name: str, slot: SlotDefinition) -> None: def main() -> None: + importmap_path = LINKML_DIR / "importmap.json" + importmap = None + if importmap_path.exists(): + raw = json.loads(importmap_path.read_text(encoding="utf-8")) + # Resolve relative paths against the linkml directory + importmap = {} + for key, val in raw.items(): + p = Path(val) + if not p.is_absolute(): + p = (LINKML_DIR / p).resolve() + importmap[key] = str(p) + for domain in DOMAINS: schema = str(LINKML_DIR / f"{domain}.yaml") + base_dir = str(LINKML_DIR) out_dir = ARTIFACTS_DIR / domain out_dir.mkdir(parents=True, exist_ok=True) print(f" Processing {domain}...") - owl_gen = OwlSchemaGenerator(schema, mergeimports=False) + owl_gen = OwlSchemaGenerator( + schema, mergeimports=False, importmap=importmap, base_dir=base_dir + ) (out_dir / f"{domain}.owl.ttl").write_text( owl_gen.serialize(), encoding="utf-8" ) - shacl_gen = HarbourShaclGenerator(schema) + shacl_gen = HarbourShaclGenerator( + schema, importmap=importmap, base_dir=base_dir + ) (out_dir / f"{domain}.shacl.ttl").write_text( shacl_gen.serialize(), encoding="utf-8" ) - ctx_gen = HarbourContextGenerator(schema, mergeimports=False) + ctx_gen = HarbourContextGenerator( + schema, mergeimports=False, importmap=importmap, base_dir=base_dir + ) ctx_text = ctx_gen.serialize() # Ensure "type": "@type" is present in the generated context. diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index 107b584..9d3e1b7 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit 107b5840d18ab96ec369b910a810fb7175ee4dab +Subproject commit 9d3e1b7809cccbe1d4e19ba47c67ee326e4bf868 diff --git a/tests/python/credentials/conftest.py b/tests/python/credentials/conftest.py index a1aa744..8c56d8b 100644 --- a/tests/python/credentials/conftest.py +++ b/tests/python/credentials/conftest.py @@ -29,7 +29,9 @@ def _b64url_decode(s: str) -> bytes: FIXTURES_DIR = _HARBOUR_ROOT / "tests" / "fixtures" KEYS_DIR = FIXTURES_DIR / "keys" EXAMPLES_DIR = _HARBOUR_ROOT / "examples" +GAIAX_EXAMPLES_DIR = EXAMPLES_DIR / "gaiax" SIGNED_DIR = EXAMPLES_DIR / "signed" +GAIAX_SIGNED_DIR = GAIAX_EXAMPLES_DIR / "signed" @pytest.fixture(scope="session") @@ -58,21 +60,39 @@ def p256_did_key_vm(p256_public_key): return f"{did}#{did.split(':')[-1]}" +def _all_example_credentials() -> list[Path]: + """Collect credential/receipt JSON files from examples/ and examples/gaiax/.""" + files: list[Path] = [] + if EXAMPLES_DIR.exists(): + files.extend(sorted(EXAMPLES_DIR.glob("*-credential.json"))) + files.extend(sorted(EXAMPLES_DIR.glob("*-receipt.json"))) + if GAIAX_EXAMPLES_DIR.exists(): + files.extend(sorted(GAIAX_EXAMPLES_DIR.glob("*-credential.json"))) + files.extend(sorted(GAIAX_EXAMPLES_DIR.glob("*-receipt.json"))) + return files + + @pytest.fixture( - params=list(EXAMPLES_DIR.glob("*-credential.json")) if EXAMPLES_DIR.exists() else [] + params=_all_example_credentials(), + ids=lambda p: (f"gaiax/{p.name}" if p.parent.name == "gaiax" else p.name), ) def example_vc(request): """Parametrized fixture for each example credential.""" return json.loads(request.param.read_text()) -@pytest.fixture( - params=( - [p for p in sorted(SIGNED_DIR.glob("*.jwt")) if ".evidence-vp." not in p.name] - if SIGNED_DIR.exists() - else [] - ) -) +def _all_signed_jwts() -> list[Path]: + """Collect pre-signed VC JWTs from signed/ dirs (excludes evidence VPs).""" + files: list[Path] = [] + for d in [SIGNED_DIR, GAIAX_SIGNED_DIR]: + if d.exists(): + files.extend( + p for p in sorted(d.glob("*.jwt")) if ".evidence-vp." not in p.name + ) + return files + + +@pytest.fixture(params=_all_signed_jwts()) def signed_jwt(request): """Parametrized fixture for each pre-signed VC JWT (excludes evidence VPs).""" return request.param.read_text().strip() diff --git a/tests/python/credentials/test_claim_mapping.py b/tests/python/credentials/test_claim_mapping.py index 831db21..f6d860b 100644 --- a/tests/python/credentials/test_claim_mapping.py +++ b/tests/python/credentials/test_claim_mapping.py @@ -4,7 +4,6 @@ from pathlib import Path from credentials.claim_mapping import ( - GAIAX_MAPPINGS, GAIAX_NS, MAPPINGS, create_mapping, @@ -22,23 +21,21 @@ GAIAX_EXAMPLES_DIR = EXAMPLES_DIR / "gaiax" -def _load_fixture(name: str, gaiax: bool = False) -> dict: - """Load a credential example from the examples directory.""" - base = GAIAX_EXAMPLES_DIR if gaiax else EXAMPLES_DIR - with open(base / name) as f: +def _load_fixture(name: str) -> dict: + """Load a credential example from the gaiax examples directory.""" + with open(GAIAX_EXAMPLES_DIR / name) as f: return json.load(f) # --------------------------------------------------------------------------- -# Base harbour skeleton tests +# Gaia-X domain tests (primary — all domain examples live in gaiax/) # --------------------------------------------------------------------------- -class TestHarbourLegalPersonMapping: +class TestGaiaxLegalPersonMapping: def test_vc_to_claims(self): - """Skeleton LegalPerson is minimal — no name, no gxParticipant.""" vc = _load_fixture("legal-person-credential.json") - mapping = MAPPINGS["harbour:LegalPersonCredential"] + mapping = MAPPINGS["harbour_gx:LegalPersonCredential"] claims, disclosable = vc_to_sd_jwt_claims(vc, mapping) assert ( @@ -49,11 +46,9 @@ def test_vc_to_claims(self): claims["sub"] == "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" ) - # Skeleton has no name — identity data lives in gxParticipant (Gaia-X layer) - assert "name" not in claims - assert "legalName" not in claims - assert "registrationNumber" not in claims - assert disclosable == [] + assert claims["legalName"] == "Example Corporation GmbH" + assert "registrationNumber" in claims + assert "registrationNumber" in disclosable def test_has_credential_status(self): vc = _load_fixture("legal-person-credential.json") @@ -62,54 +57,43 @@ def test_has_credential_status(self): assert status["type"] == "harbour:CRSetEntry" assert status["statusPurpose"] == "revocation" - def test_subject_is_harbour_legal_person(self): - """Verify the subject uses harbour:LegalPerson (outer node only).""" + def test_subject_is_harbour_gx_legal_person(self): + """Verify the subject uses harbour_gx:LegalPerson.""" vc = _load_fixture("legal-person-credential.json") subject_type = vc["credentialSubject"]["type"] - assert subject_type == "harbour:LegalPerson" - - def test_no_gx_participant(self): - """Base skeleton must not contain gxParticipant.""" - vc = _load_fixture("legal-person-credential.json") - assert "gxParticipant" not in vc["credentialSubject"] - - def test_no_name_on_outer_node(self): - """Base skeleton must not have name on the outer node.""" - vc = _load_fixture("legal-person-credential.json") - assert "name" not in vc["credentialSubject"] + assert subject_type == "harbour_gx:LegalPerson" - def test_no_gaiax_context(self): - """Base skeleton must not reference the Gaia-X namespace.""" + def test_has_gaiax_context(self): + """Gaia-X extension must include the Gaia-X namespace in @context.""" vc = _load_fixture("legal-person-credential.json") - assert GAIAX_NS not in vc["@context"] + assert GAIAX_NS in vc["@context"] def test_roundtrip(self): - """Roundtrip skeleton — no claims to map, just envelope.""" vc = _load_fixture("legal-person-credential.json") - mapping = MAPPINGS["harbour:LegalPersonCredential"] + mapping = MAPPINGS["harbour_gx:LegalPersonCredential"] claims, _ = vc_to_sd_jwt_claims(vc, mapping) reconstructed = sd_jwt_claims_to_vc( - claims, mapping, "harbour:LegalPersonCredential" + claims, mapping, "harbour_gx:LegalPersonCredential" + ) + assert ( + reconstructed["credentialSubject"]["name"] + == vc["credentialSubject"]["name"] ) - # Skeleton has no name — just verify subject ID roundtrips - assert reconstructed["credentialSubject"]["id"] == vc["credentialSubject"]["id"] -class TestHarbourNaturalPersonMapping: +class TestGaiaxNaturalPersonMapping: def test_vc_to_claims(self): - """Skeleton NaturalPerson is minimal — just memberOf, no personal attributes.""" vc = _load_fixture("natural-person-credential.json") - mapping = MAPPINGS["harbour:NaturalPersonCredential"] + mapping = MAPPINGS["harbour_gx:NaturalPersonCredential"] claims, disclosable = vc_to_sd_jwt_claims(vc, mapping) + assert claims["givenName"] == "Alice" + assert claims["familyName"] == "Smith" + assert "givenName" in disclosable assert ( claims["memberOf"] == "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" ) - assert "memberOf" in disclosable - # No personal attributes in skeleton — those come via gxParticipant - assert "givenName" not in claims - assert "familyName" not in claims def test_has_credential_status(self): vc = _load_fixture("natural-person-credential.json") @@ -121,115 +105,21 @@ def test_has_evidence(self): vc = _load_fixture("natural-person-credential.json") assert "evidence" in vc evidence = vc["evidence"][0] - assert evidence["type"] == "harbour:CredentialEvidence" - - def test_subject_is_harbour_natural_person(self): - """Verify the subject uses harbour:NaturalPerson (outer node).""" + ev_type = evidence["type"] + if isinstance(ev_type, list): + assert "harbour:CredentialEvidence" in ev_type + else: + assert ev_type == "harbour:CredentialEvidence" + + def test_subject_is_harbour_gx_natural_person(self): + """Verify the subject uses harbour_gx:NaturalPerson.""" vc = _load_fixture("natural-person-credential.json") subject_type = vc["credentialSubject"]["type"] - assert subject_type == "harbour:NaturalPerson" - - def test_no_gx_participant(self): - """Skeleton is minimal — no gxParticipant.""" - vc = _load_fixture("natural-person-credential.json") - assert "gxParticipant" not in vc["credentialSubject"] - - def test_no_personal_attributes(self): - """Skeleton carries no personal attributes — identity comes via gx layer.""" - vc = _load_fixture("natural-person-credential.json") - subject = vc["credentialSubject"] - assert "givenName" not in subject - assert "familyName" not in subject - assert "email" not in subject - assert "name" not in subject - - def test_no_gaiax_context(self): - """Base skeleton must not reference the Gaia-X namespace.""" - vc = _load_fixture("natural-person-credential.json") - assert GAIAX_NS not in vc["@context"] - - -# --------------------------------------------------------------------------- -# Gaia-X domain extension tests -# --------------------------------------------------------------------------- - - -class TestGaiaxLegalPersonMapping: - def test_vc_to_claims(self): - vc = _load_fixture("legal-person-credential.json", gaiax=True) - mapping = GAIAX_MAPPINGS["harbour:LegalPersonCredential"] - claims, disclosable = vc_to_sd_jwt_claims(vc, mapping) - - # Gaia-X extension uses gxParticipant.name, not outer name - assert "name" not in claims - assert claims["legalName"] == "Example Corporation GmbH" - assert "registrationNumber" in claims - assert "registrationNumber" in disclosable - - def test_gx_inner_node_exists(self): - """Verify gx:LegalPerson data lives in the gxParticipant inner node.""" - vc = _load_fixture("legal-person-credential.json", gaiax=True) - subject = vc["credentialSubject"] - gx = subject["gxParticipant"] - assert gx["type"] == "gx:LegalPerson" - assert "name" in gx - assert "gx:registrationNumber" in gx - # gx properties must NOT be on the outer node - assert "gx:registrationNumber" not in subject + assert subject_type == "harbour_gx:NaturalPerson" def test_has_gaiax_context(self): """Gaia-X extension must include the Gaia-X namespace in @context.""" - vc = _load_fixture("legal-person-credential.json", gaiax=True) - assert GAIAX_NS in vc["@context"] - - def test_no_outer_name(self): - """Gaia-X extension should NOT have name on the outer node.""" - vc = _load_fixture("legal-person-credential.json", gaiax=True) - assert "name" not in vc["credentialSubject"] - - def test_roundtrip(self): - vc = _load_fixture("legal-person-credential.json", gaiax=True) - mapping = GAIAX_MAPPINGS["harbour:LegalPersonCredential"] - claims, _ = vc_to_sd_jwt_claims(vc, mapping) - reconstructed = sd_jwt_claims_to_vc( - claims, mapping, "harbour:LegalPersonCredential" - ) - assert ( - reconstructed["credentialSubject"]["gxParticipant"]["name"] - == vc["credentialSubject"]["gxParticipant"]["name"] - ) - - -class TestGaiaxNaturalPersonMapping: - def test_vc_to_claims(self): - vc = _load_fixture("natural-person-credential.json", gaiax=True) - mapping = GAIAX_MAPPINGS["harbour:NaturalPersonCredential"] - claims, disclosable = vc_to_sd_jwt_claims(vc, mapping) - - assert claims["givenName"] == "Alice" - assert claims["familyName"] == "Smith" - assert "givenName" in disclosable - - def test_gx_natural_person_inner_node(self): - """Verify gx:NaturalPerson data lives in the gxParticipant inner node.""" - vc = _load_fixture("natural-person-credential.json", gaiax=True) - subject = vc["credentialSubject"] - gx = subject["gxParticipant"] - assert gx["type"] == "gx:NaturalPerson" - assert "givenName" in gx - assert "familyName" in gx - - def test_no_personal_attributes_on_outer_node(self): - """givenName/familyName/email must NOT be on the outer node.""" - vc = _load_fixture("natural-person-credential.json", gaiax=True) - subject = vc["credentialSubject"] - assert "givenName" not in subject - assert "familyName" not in subject - assert "name" not in subject - - def test_has_gaiax_context(self): - """Gaia-X extension must include the Gaia-X namespace in @context.""" - vc = _load_fixture("natural-person-credential.json", gaiax=True) + vc = _load_fixture("natural-person-credential.json") assert GAIAX_NS in vc["@context"] @@ -239,23 +129,21 @@ def test_has_gaiax_context(self): class TestMappingDiscovery: - def test_get_mapping_for_harbour_skeleton(self): - """Base skeleton (no Gaia-X context) should return base mapping.""" + def test_get_mapping_for_gaiax_legal_person(self): + """Gaia-X legal person should return the flat mapping.""" vc = _load_fixture("legal-person-credential.json") mapping = get_mapping_for_vc(vc) assert mapping is not None assert "LegalPersonCredential" in mapping["vct"] - # Base mapping should NOT have gxParticipant paths - assert "credentialSubject.gxParticipant.name" not in mapping["claims"] + assert "credentialSubject.name" in mapping["claims"] - def test_get_mapping_for_gaiax_extension(self): - """Gaia-X extension (with Gaia-X context) should return Gaia-X mapping.""" - vc = _load_fixture("legal-person-credential.json", gaiax=True) + def test_get_mapping_for_gaiax_natural_person(self): + """Gaia-X natural person should return the flat mapping.""" + vc = _load_fixture("natural-person-credential.json") mapping = get_mapping_for_vc(vc) assert mapping is not None - assert "LegalPersonCredential" in mapping["vct"] - # Gaia-X mapping should have gxParticipant paths - assert "credentialSubject.gxParticipant.name" in mapping["claims"] + assert "NaturalPersonCredential" in mapping["vct"] + assert "credentialSubject.givenName" in mapping["claims"] def test_get_mapping_for_unknown(self): vc = {"type": ["VerifiableCredential", "UnknownType"]} diff --git a/tests/python/credentials/test_example_signer.py b/tests/python/credentials/test_example_signer.py index d0afb16..caf3515 100644 --- a/tests/python/credentials/test_example_signer.py +++ b/tests/python/credentials/test_example_signer.py @@ -129,10 +129,9 @@ def test_process_example_with_evidence(self, signing_key, tmp_path): """Process an example with evidence VP through the full pipeline.""" private_key, public_key, kid = signing_key - # Load legal person example (has evidence) - example_path = EXAMPLES_DIR / "legal-person-credential.json" + example_path = GAIAX_EXAMPLES_DIR / "legal-person-credential.json" if not example_path.exists(): - pytest.skip("examples/ not populated") + pytest.skip("examples/gaiax/ not populated") output_dir = tmp_path / "signed" jwt_path = process_example(example_path, private_key, kid, output_dir) @@ -149,7 +148,7 @@ def test_process_example_with_evidence(self, signing_key, tmp_path): vc_jwt = jwt_path.read_text().strip() vc_payload = verify_vc_jose(vc_jwt, public_key) assert vc_payload["id"] == "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890" - assert "harbour:LegalPersonCredential" in vc_payload["type"] + assert "harbour_gx:LegalPersonCredential" in vc_payload["type"] # Evidence should now be a JWT string evidence = vc_payload["evidence"][0] @@ -165,9 +164,9 @@ def test_process_delegated_signing_receipt(self, signing_key, tmp_path): """Process the delegated signing receipt with DelegatedSignatureEvidence.""" private_key, public_key, kid = signing_key - example_path = EXAMPLES_DIR / "delegated-signing-receipt.json" + example_path = GAIAX_EXAMPLES_DIR / "delegated-signing-receipt.json" if not example_path.exists(): - pytest.skip("examples/ not populated") + pytest.skip("examples/gaiax/ not populated") output_dir = tmp_path / "signed" jwt_path = process_example(example_path, private_key, kid, output_dir) @@ -184,7 +183,11 @@ def test_process_delegated_signing_receipt(self, signing_key, tmp_path): # Evidence should contain DelegatedSignatureEvidence with transaction_data evidence = vc_payload["evidence"][0] - assert evidence["type"] == "harbour:DelegatedSignatureEvidence" + ev_type = evidence["type"] + if isinstance(ev_type, list): + assert "harbour:DelegatedSignatureEvidence" in ev_type + else: + assert ev_type == "harbour:DelegatedSignatureEvidence" assert "transaction_data" in evidence assert evidence["transaction_data"]["type"] == "harbour_delegate:data.purchase" assert ( @@ -198,12 +201,16 @@ def test_process_delegated_signing_receipt(self, signing_key, tmp_path): assert vp_jwt_str.count(".") == 2 def test_process_all_examples(self, signing_key, tmp_path): - """Process all examples and verify each produces a valid JWT.""" + """Process all examples (root + gaiax) and verify each produces a valid JWT.""" private_key, public_key, kid = signing_key example_files = sorted(EXAMPLES_DIR.glob("*-credential.json")) + example_files += sorted(EXAMPLES_DIR.glob("*-receipt.json")) + if GAIAX_EXAMPLES_DIR.is_dir(): + example_files += sorted(GAIAX_EXAMPLES_DIR.glob("*-credential.json")) + example_files += sorted(GAIAX_EXAMPLES_DIR.glob("*-receipt.json")) if not example_files: - pytest.skip("examples/ not populated") + pytest.skip("No examples found") output_dir = tmp_path / "signed" for path in example_files: @@ -234,12 +241,12 @@ def test_process_gaiax_legal_person(self, signing_key, tmp_path): # Verify outer VC JWT vc_jwt = jwt_path.read_text().strip() vc_payload = verify_vc_jose(vc_jwt, public_key) - assert "harbour:LegalPersonCredential" in vc_payload["type"] + assert "harbour_gx:LegalPersonCredential" in vc_payload["type"] - # Gaia-X credential should have gxParticipant in subject + # Subject should have the LegalPerson data directly subject = vc_payload["credentialSubject"] - assert "gxParticipant" in subject - assert subject["gxParticipant"]["type"] == "gx:LegalPerson" + assert subject["type"] == "harbour_gx:LegalPerson" + assert "name" in subject def test_process_gaiax_natural_person(self, signing_key, tmp_path): """Process the Gaia-X natural person credential through the pipeline.""" @@ -255,11 +262,12 @@ def test_process_gaiax_natural_person(self, signing_key, tmp_path): assert jwt_path.exists() vc_jwt = jwt_path.read_text().strip() vc_payload = verify_vc_jose(vc_jwt, public_key) - assert "harbour:NaturalPersonCredential" in vc_payload["type"] + assert "harbour_gx:NaturalPersonCredential" in vc_payload["type"] - # Gaia-X credential should have gxParticipant in subject + # Subject should have the NaturalPerson data directly subject = vc_payload["credentialSubject"] - assert "gxParticipant" in subject + assert subject["type"] == "harbour_gx:NaturalPerson" + assert "givenName" in subject def test_process_all_gaiax_examples(self, signing_key, tmp_path): """Process all Gaia-X examples and verify each produces a valid JWT.""" diff --git a/tests/python/credentials/test_validation.py b/tests/python/credentials/test_validation.py index fce3add..5de8707 100644 --- a/tests/python/credentials/test_validation.py +++ b/tests/python/credentials/test_validation.py @@ -59,7 +59,10 @@ def _all_credential_files() -> list[Path]: # --------------------------------------------------------------------------- -@pytest.fixture(params=_all_credential_files(), ids=lambda p: p.name) +@pytest.fixture( + params=_all_credential_files(), + ids=lambda p: f"gaiax/{p.name}" if p.parent.name == "gaiax" else p.name, +) def credential_file(request): return request.param @@ -119,21 +122,22 @@ def test_has_credential_status(credential_file): def test_credential_subject_has_type(credential_file): - """Each credential subject must have a type (singular harbour type).""" + """Domain credentials must have a typed credentialSubject.""" data = _load_json(credential_file) subject = data.get("credentialSubject", {}) - assert ( - "type" in subject - ), f"Missing credentialSubject.type in {credential_file.name}" + # Core skeleton credentials (credential-with-evidence, etc.) may have + # untyped subjects — only domain credentials require a type. + if "type" not in subject: + return subject_type = subject["type"] - # Subject type should be a singular harbour type (not a dual-type array) if isinstance(subject_type, str): - assert subject_type.startswith( - "harbour:" + assert subject_type.startswith("harbour:") or subject_type.startswith( + "harbour_gx:" ), f"Subject type should be harbour-prefixed, got: {subject_type}" elif isinstance(subject_type, list): assert any( - t.startswith("harbour:") for t in subject_type + t.startswith("harbour:") or t.startswith("harbour_gx:") + for t in subject_type ), f"Subject type list should include a harbour type: {subject_type}" @@ -195,6 +199,7 @@ class TestDomainContextConsistency: def test_context_has_domain_classes(self): ctx = _load_json(DOMAIN_CONTEXT_PATH).get("@context", {}) + has_vocab = "@vocab" in ctx domain_classes = [ "LegalPersonCredential", "NaturalPersonCredential", @@ -202,7 +207,10 @@ def test_context_has_domain_classes(self): "NaturalPerson", ] for cls in domain_classes: - assert cls in ctx, f"Missing {cls} in harbour-gx-credential context" + # Term resolves either via explicit context entry or @vocab fallback + assert ( + cls in ctx or has_vocab + ), f"Missing {cls} in harbour-gx-credential context (no @vocab fallback)" def test_context_has_composition_slots(self): ctx = _load_json(DOMAIN_CONTEXT_PATH).get("@context", {}) @@ -210,16 +218,19 @@ def test_context_has_composition_slots(self): def test_domain_class_iris_are_prefixed(self): ctx = _load_json(DOMAIN_CONTEXT_PATH).get("@context", {}) + has_vocab = "@vocab" in ctx domain_classes = [ "LegalPerson", "NaturalPerson", "LegalPersonCredential", "NaturalPersonCredential", ] - has_vocab = "@vocab" in ctx for cls in domain_classes: entry = ctx.get(cls) - assert entry is not None, f"Missing {cls} in context" + if entry is None: + # Term resolves via @vocab — that's fine + assert has_vocab, f"Missing {cls} in context with no @vocab" + continue aid = entry.get("@id") if isinstance(entry, dict) else entry assert ( has_vocab or ":" in aid @@ -306,10 +317,10 @@ def test_shacl_is_non_empty(self): def test_shacl_has_domain_shapes(self): content = DOMAIN_SHACL_PATH.read_text() expected_shapes = [ - "harbour:LegalPersonCredential", - "harbour:NaturalPersonCredential", - "harbour:LegalPerson", - "harbour:NaturalPerson", + "harbour_gx:LegalPersonCredential", + "harbour_gx:NaturalPersonCredential", + "harbour_gx:LegalPerson", + "harbour_gx:NaturalPerson", ] for shape in expected_shapes: assert ( @@ -323,7 +334,7 @@ def test_credential_shapes_have_required_properties(self): "LegalPersonCredential", "NaturalPersonCredential", ]: - marker = f"harbour:{cred_type} a sh:NodeShape" + marker = f"harbour_gx:{cred_type} a sh:NodeShape" assert marker in content, f"Missing shape for {cred_type}" shape_start = content.index(marker) next_shape = content.find("\n\n", shape_start + 1) @@ -341,7 +352,7 @@ def test_person_credential_shapes_require_evidence(self): """LegalPersonCredential and NaturalPersonCredential must require evidence.""" content = DOMAIN_SHACL_PATH.read_text() for cred_type in ["LegalPersonCredential", "NaturalPersonCredential"]: - marker = f"harbour:{cred_type} a sh:NodeShape" + marker = f"harbour_gx:{cred_type} a sh:NodeShape" assert marker in content, f"Missing shape for {cred_type}" shape_start = content.index(marker) next_shape = content.find("\n\n", shape_start + 1) From 0038732b1e13ab630719974056c33504bc083727 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Wed, 11 Mar 2026 17:03:34 +0100 Subject: [PATCH 24/78] chore: replace black/isort/flake8 with ruff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace black, isort, flake8 with ruff for linting and formatting - Update .pre-commit-config.yaml: 3 hooks → 2 (ruff format + ruff check) - Update pyproject.toml: remove [tool.black]/[tool.isort], add [tool.ruff] - Delete .flake8 config (rules moved to pyproject.toml) - Update Makefile format target to use ruff - Update CLAUDE.md tooling reference - Auto-fix all ruff formatting and import sorting Signed-off-by: Carlo van Driesten --- .flake8 | 10 --- .pre-commit-config.yaml | 22 ++--- CLAUDE.md | 2 +- Makefile | 6 +- pyproject.toml | 28 +++---- src/python/credentials/example_signer.py | 1 + src/python/harbour/_crypto.py | 3 +- src/python/harbour/kb_jwt.py | 6 +- src/python/harbour/sd_jwt.py | 3 +- src/python/harbour/sd_jwt_vp.py | 3 +- src/python/harbour/signer.py | 3 +- src/python/harbour/verifier.py | 6 +- src/python/harbour/x509.py | 2 + tests/conftest.py | 1 + tests/interop/test_cross_runtime.py | 13 +-- tests/python/credentials/conftest.py | 3 +- .../python/credentials/test_example_signer.py | 1 + .../python/credentials/test_sign_examples.py | 7 +- tests/python/credentials/test_validation.py | 84 +++++++++---------- tests/python/harbour/test_delegation.py | 7 +- tests/python/harbour/test_kb_jwt.py | 1 + tests/python/harbour/test_keys.py | 1 + tests/python/harbour/test_sd_jwt.py | 1 + tests/python/harbour/test_sd_jwt_vp.py | 3 +- tests/python/harbour/test_tamper.py | 7 +- tests/python/harbour/test_verify.py | 1 + tests/python/harbour/test_x509.py | 1 + 27 files changed, 109 insertions(+), 117 deletions(-) delete mode 100644 .flake8 diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 1e11c1b..0000000 --- a/.flake8 +++ /dev/null @@ -1,10 +0,0 @@ -[flake8] -max-line-length = 88 -extend-ignore = E203, E501, W503 -exclude = - .git, - .venv, - build, - dist, - node_modules, - __pycache__ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 54ccf95..d423978 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,28 +2,18 @@ repos: - repo: local hooks: - - id: black - name: black - entry: black + - id: ruff-format + name: ruff format + entry: ruff format language: system types: [python] - args: ["--config=pyproject.toml"] pass_filenames: true - - id: isort - name: isort - entry: isort + - id: ruff-check + name: ruff check + entry: ruff check --fix language: system types: [python] - args: ["--settings=pyproject.toml"] - pass_filenames: true - - - id: flake8 - name: flake8 - entry: flake8 - language: system - types: [python] - args: ["--config=.flake8"] pass_filenames: true - repo: https://github.com/DavidAnson/markdownlint-cli2 diff --git a/CLAUDE.md b/CLAUDE.md index ab04008..4088b9b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,7 +139,7 @@ All Python modules have CLI interfaces: `python -m harbour.keys --help`, `python - **Python 3.12+** with type hints on public APIs - **pathlib.Path** (never `os.path`) - All modules must have `main()` with `argparse` and `--help` -- Formatter: black (line-length 88), isort (profile: black) +- Formatter/linter: ruff (line-length 88, rules: E/F/W/I) ### TypeScript diff --git a/Makefile b/Makefile index 39913de..61465dd 100644 --- a/Makefile +++ b/Makefile @@ -81,7 +81,7 @@ help: @echo " make lint - Run pre-commit checks (Python + Markdown)" @echo " make lint-md - Lint Markdown files with markdownlint-cli2" @echo " make lint-ts - Run TypeScript linting" - @echo " make format - Format Python code with black/isort" + @echo " make format - Format Python code with ruff" @echo " make format-md - Auto-fix Markdown lint violations" @echo "" @echo "Testing:" @@ -238,8 +238,8 @@ lint-md: ## Lint Markdown files with markdownlint-cli2 format: $(call check_dev_setup) @echo "Formatting Python code..." - @$(PYTHON) -m black src/python/ tests/ - @$(PYTHON) -m isort src/python/ tests/ + @$(PYTHON) -m ruff format src/python/ tests/ + @$(PYTHON) -m ruff check --fix src/python/ tests/ @echo "OK: Python formatting complete" # Auto-fix Markdown lint violations diff --git a/pyproject.toml b/pyproject.toml index 549a176..9dfb3f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,9 +31,7 @@ dependencies = [ [project.optional-dependencies] dev = [ - "black==25.12.0", - "flake8==7.1.2", - "isort==7.0.0", + "ruff>=0.15.0", "pre-commit==4.5.1", "pytest>=8.0.0", "pytest-cov>=6.0", @@ -54,23 +52,17 @@ Issues = "https://github.com/reachhaven/harbour-credentials/issues" where = ["src/python"] include = ["harbour*", "credentials*"] -[tool.black] +[tool.ruff] line-length = 88 -target-version = ["py312"] -exclude = ''' -/( - \.git - | \.venv - | build - | dist - | node_modules -)/ -''' +target-version = "py312" +exclude = [".git", ".venv", "build", "dist", "node_modules"] -[tool.isort] -profile = "black" -line_length = 88 -skip = [".git", ".venv", "build", "dist", "node_modules"] +[tool.ruff.lint] +select = ["E", "F", "W", "I"] +ignore = ["E203", "E501"] + +[tool.ruff.lint.isort] +known-first-party = ["harbour", "credentials"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/python/credentials/example_signer.py b/src/python/credentials/example_signer.py index 19b5b92..f36b97c 100644 --- a/src/python/credentials/example_signer.py +++ b/src/python/credentials/example_signer.py @@ -31,6 +31,7 @@ EllipticCurvePrivateNumbers, EllipticCurvePublicNumbers, ) + from harbour.keys import PrivateKey, p256_public_key_to_did_key from harbour.signer import sign_vc_jose, sign_vp_jose diff --git a/src/python/harbour/_crypto.py b/src/python/harbour/_crypto.py index 1096e34..292b76a 100644 --- a/src/python/harbour/_crypto.py +++ b/src/python/harbour/_crypto.py @@ -17,6 +17,8 @@ Ed25519PrivateKey, Ed25519PublicKey, ) +from joserfc.jwk import ECKey, OKPKey + from harbour.keys import ( PrivateKey, PublicKeyType, @@ -26,7 +28,6 @@ p256_public_key_to_jwk, public_key_to_jwk, ) -from joserfc.jwk import ECKey, OKPKey def import_private_key(private_key: PrivateKey, alg: str) -> ECKey | OKPKey: diff --git a/src/python/harbour/kb_jwt.py b/src/python/harbour/kb_jwt.py index f6d557a..2f39d85 100644 --- a/src/python/harbour/kb_jwt.py +++ b/src/python/harbour/kb_jwt.py @@ -17,13 +17,14 @@ import time from pathlib import Path +from joserfc import jws + from harbour._crypto import import_private_key as _import_private_key from harbour._crypto import import_public_key as _import_public_key from harbour._crypto import resolve_private_key_alg as _resolve_alg from harbour._crypto import resolve_public_key_alg as _alg_for_key from harbour.keys import PrivateKey, PublicKeyType from harbour.verifier import VerificationError -from joserfc import jws SD_JWT_SEPARATOR = "~" @@ -148,8 +149,7 @@ def verify_kb_jwt( # Verify nonce if payload.get("nonce") != expected_nonce: raise VerificationError( - f"Nonce mismatch: expected {expected_nonce!r}, " - f"got {payload.get('nonce')!r}" + f"Nonce mismatch: expected {expected_nonce!r}, got {payload.get('nonce')!r}" ) # Verify audience diff --git a/src/python/harbour/sd_jwt.py b/src/python/harbour/sd_jwt.py index 44d17fb..37826fa 100644 --- a/src/python/harbour/sd_jwt.py +++ b/src/python/harbour/sd_jwt.py @@ -17,13 +17,14 @@ import sys from pathlib import Path +from joserfc import jws + from harbour._crypto import import_private_key as _import_private_key from harbour._crypto import import_public_key as _import_public_key from harbour._crypto import resolve_private_key_alg as _resolve_alg from harbour._crypto import resolve_public_key_alg as _alg_for_key from harbour.keys import PrivateKey, PublicKeyType from harbour.verifier import VerificationError -from joserfc import jws # SD-JWT uses ~-delimited format: ~~~...~ SD_JWT_SEPARATOR = "~" diff --git a/src/python/harbour/sd_jwt_vp.py b/src/python/harbour/sd_jwt_vp.py index c9a1aa5..1417546 100644 --- a/src/python/harbour/sd_jwt_vp.py +++ b/src/python/harbour/sd_jwt_vp.py @@ -30,6 +30,8 @@ from copy import deepcopy from pathlib import Path +from joserfc import jws + from harbour._crypto import import_private_key as _import_private_key from harbour._crypto import import_public_key as _import_public_key from harbour._crypto import load_private_key as _load_private_key @@ -43,7 +45,6 @@ ) from harbour.keys import PrivateKey, PublicKeyType from harbour.verifier import VerificationError -from joserfc import jws # SD-JWT uses ~-delimited format SD_JWT_SEPARATOR = "~" diff --git a/src/python/harbour/signer.py b/src/python/harbour/signer.py index f95c8e0..f4f09bf 100644 --- a/src/python/harbour/signer.py +++ b/src/python/harbour/signer.py @@ -11,11 +11,12 @@ import sys from pathlib import Path +from joserfc import jws + from harbour._crypto import import_private_key as _import_private_key from harbour._crypto import load_private_key as _load_private_key from harbour._crypto import resolve_private_key_alg as _resolve_alg from harbour.keys import PrivateKey -from joserfc import jws def sign_vc_jose( diff --git a/src/python/harbour/verifier.py b/src/python/harbour/verifier.py index 51f76d2..6525f6a 100644 --- a/src/python/harbour/verifier.py +++ b/src/python/harbour/verifier.py @@ -11,11 +11,12 @@ import sys from pathlib import Path +from joserfc import jws + from harbour._crypto import import_public_key as _import_public_key from harbour._crypto import load_public_key as _load_public_key from harbour._crypto import resolve_public_key_alg as _alg_for_key from harbour.keys import PublicKeyType -from joserfc import jws class VerificationError(Exception): @@ -72,8 +73,7 @@ def verify_vp_jose( actual_aud = payload.get("aud") if actual_aud != expected_audience: raise VerificationError( - f"Audience mismatch: expected {expected_audience!r}, " - f"got {actual_aud!r}" + f"Audience mismatch: expected {expected_audience!r}, got {actual_aud!r}" ) return payload diff --git a/src/python/harbour/x509.py b/src/python/harbour/x509.py index d5eaaaa..de15ee6 100644 --- a/src/python/harbour/x509.py +++ b/src/python/harbour/x509.py @@ -25,6 +25,7 @@ from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from cryptography.x509.oid import NameOID + from harbour.keys import PrivateKey, PublicKeyType @@ -288,6 +289,7 @@ def main(): pub_key = extract_public_key(cert) from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + from harbour.keys import p256_public_key_to_jwk, public_key_to_jwk if isinstance(pub_key, Ed25519PublicKey): diff --git a/tests/conftest.py b/tests/conftest.py index 46760c6..c753a12 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,7 @@ EllipticCurvePublicNumbers, ) from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + from harbour.keys import p256_public_key_to_did_key FIXTURES_DIR = Path(__file__).parent / "fixtures" diff --git a/tests/interop/test_cross_runtime.py b/tests/interop/test_cross_runtime.py index 34fe036..937eb33 100644 --- a/tests/interop/test_cross_runtime.py +++ b/tests/interop/test_cross_runtime.py @@ -5,6 +5,7 @@ from pathlib import Path import pytest + from harbour.delegation import ( TransactionData, compute_transaction_data_param_hash, @@ -239,9 +240,9 @@ def test_canonical_json_matches(self, vectors): for v in vectors: td = TransactionData.from_dict(v["input"]) py_canonical = td.to_json(canonical=True) - assert ( - py_canonical == v["canonical_json"] - ), f"Python mismatch for {v['name']}" + assert py_canonical == v["canonical_json"], ( + f"Python mismatch for {v['name']}" + ) # Run all vectors through TypeScript in a single Node invocation inputs_json = json.dumps([v["input"] for v in vectors]) @@ -312,9 +313,9 @@ def test_transaction_data_param_matches(self, vectors): """Both runtimes produce the same base64url transaction_data request strings.""" for v in vectors: td = TransactionData.from_dict(v["input"]) - assert ( - encode_transaction_data_param(td) == v["transaction_data_param"] - ), f"Python mismatch for {v['name']}" + assert encode_transaction_data_param(td) == v["transaction_data_param"], ( + f"Python mismatch for {v['name']}" + ) inputs_json = json.dumps([v["input"] for v in vectors]) expected_json = json.dumps([v["transaction_data_param"] for v in vectors]) diff --git a/tests/python/credentials/conftest.py b/tests/python/credentials/conftest.py index 8c56d8b..2bde83c 100644 --- a/tests/python/credentials/conftest.py +++ b/tests/python/credentials/conftest.py @@ -9,6 +9,7 @@ EllipticCurvePrivateNumbers, EllipticCurvePublicNumbers, ) + from harbour.keys import p256_public_key_to_did_key @@ -74,7 +75,7 @@ def _all_example_credentials() -> list[Path]: @pytest.fixture( params=_all_example_credentials(), - ids=lambda p: (f"gaiax/{p.name}" if p.parent.name == "gaiax" else p.name), + ids=lambda p: f"gaiax/{p.name}" if p.parent.name == "gaiax" else p.name, ) def example_vc(request): """Parametrized fixture for each example credential.""" diff --git a/tests/python/credentials/test_example_signer.py b/tests/python/credentials/test_example_signer.py index caf3515..4df2713 100644 --- a/tests/python/credentials/test_example_signer.py +++ b/tests/python/credentials/test_example_signer.py @@ -10,6 +10,7 @@ from pathlib import Path import pytest + from credentials.example_signer import ( decode_evidence_vp, load_test_p256_keypair, diff --git a/tests/python/credentials/test_sign_examples.py b/tests/python/credentials/test_sign_examples.py index a98049b..a79fc1b 100644 --- a/tests/python/credentials/test_sign_examples.py +++ b/tests/python/credentials/test_sign_examples.py @@ -1,6 +1,7 @@ """Sign and verify all example credentials from examples/.""" import pytest + from harbour.signer import sign_vc_jose from harbour.verifier import VerificationError, verify_vc_jose @@ -30,9 +31,9 @@ def test_tamper_detection_jose( parts = token.split(".") payload = json.loads(base64.urlsafe_b64decode(parts[1] + "==")) - payload["credentialSubject"][ - "id" - ] = "did:ethr:0x14a34:0x81c6d42b1781bb3bb7a280f564d66ec9d41beace" + payload["credentialSubject"]["id"] = ( + "did:ethr:0x14a34:0x81c6d42b1781bb3bb7a280f564d66ec9d41beace" + ) tampered_payload = ( base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=").decode() ) diff --git a/tests/python/credentials/test_validation.py b/tests/python/credentials/test_validation.py index 5de8707..15feb4a 100644 --- a/tests/python/credentials/test_validation.py +++ b/tests/python/credentials/test_validation.py @@ -77,9 +77,9 @@ def test_has_context(credential_file): """Each credential must have an @context array.""" data = _load_json(credential_file) ctx = data.get("@context") - assert isinstance( - ctx, list - ), f"Missing or invalid @context in {credential_file.name}" + assert isinstance(ctx, list), ( + f"Missing or invalid @context in {credential_file.name}" + ) assert "https://www.w3.org/ns/credentials/v2" in ctx @@ -87,9 +87,9 @@ def test_has_type(credential_file): """Each credential must have a type array with VerifiableCredential.""" data = _load_json(credential_file) types = data.get("type", []) - assert ( - "VerifiableCredential" in types - ), f"Missing VerifiableCredential type in {credential_file.name}" + assert "VerifiableCredential" in types, ( + f"Missing VerifiableCredential type in {credential_file.name}" + ) def test_has_issuer(credential_file): @@ -113,9 +113,9 @@ def test_has_credential_status(credential_file): """Each harbour credential must have a credentialStatus with CRSetEntry.""" data = _load_json(credential_file) status = data.get("credentialStatus") - assert ( - isinstance(status, list) and len(status) > 0 - ), f"Missing credentialStatus in {credential_file.name}" + assert isinstance(status, list) and len(status) > 0, ( + f"Missing credentialStatus in {credential_file.name}" + ) for entry in status: assert entry.get("type") == "harbour:CRSetEntry" assert "statusPurpose" in entry @@ -178,9 +178,9 @@ def test_base_class_iris_are_prefixed(self): entry = ctx.get(cls) assert entry is not None, f"Missing {cls} in context" aid = entry.get("@id") if isinstance(entry, dict) else entry - assert ( - has_vocab or ":" in aid - ), f"{cls} has unprefixed @id without @vocab: {aid}" + assert has_vocab or ":" in aid, ( + f"{cls} has unprefixed @id without @vocab: {aid}" + ) # --------------------------------------------------------------------------- @@ -208,9 +208,9 @@ def test_context_has_domain_classes(self): ] for cls in domain_classes: # Term resolves either via explicit context entry or @vocab fallback - assert ( - cls in ctx or has_vocab - ), f"Missing {cls} in harbour-gx-credential context (no @vocab fallback)" + assert cls in ctx or has_vocab, ( + f"Missing {cls} in harbour-gx-credential context (no @vocab fallback)" + ) def test_context_has_composition_slots(self): ctx = _load_json(DOMAIN_CONTEXT_PATH).get("@context", {}) @@ -232,9 +232,9 @@ def test_domain_class_iris_are_prefixed(self): assert has_vocab, f"Missing {cls} in context with no @vocab" continue aid = entry.get("@id") if isinstance(entry, dict) else entry - assert ( - has_vocab or ":" in aid - ), f"{cls} has unprefixed @id without @vocab: {aid}" + assert has_vocab or ":" in aid, ( + f"{cls} has unprefixed @id without @vocab: {aid}" + ) # --------------------------------------------------------------------------- @@ -262,9 +262,9 @@ def test_shacl_has_base_shapes(self): "harbour:DelegatedSignatureEvidence", ] for shape in expected_shapes: - assert ( - f"{shape} a sh:NodeShape" in content - ), f"Missing SHACL NodeShape for {shape}" + assert f"{shape} a sh:NodeShape" in content, ( + f"Missing SHACL NodeShape for {shape}" + ) def test_harbour_credential_shape_has_issuer(self): """HarbourCredential shape must include cred:issuer as required.""" @@ -276,9 +276,9 @@ def test_harbour_credential_shape_has_issuer(self): if next_shape == -1: next_shape = len(content) shape_block = content[shape_start:next_shape] - assert ( - "cred:issuer" in shape_block - ), "HarbourCredential shape missing cred:issuer" + assert "cred:issuer" in shape_block, ( + "HarbourCredential shape missing cred:issuer" + ) def test_evidence_shapes_require_verifiable_presentation(self): """Evidence shapes must require verifiablePresentation.""" @@ -290,12 +290,12 @@ def test_evidence_shapes_require_verifiable_presentation(self): if next_shape == -1: next_shape = len(content) shape_block = content[shape_start:next_shape] - assert ( - "harbour:verifiablePresentation" in shape_block - ), f"{ev_type} shape missing harbour:verifiablePresentation" - assert ( - "sh:minCount 1" in shape_block - ), f"{ev_type} shape missing sh:minCount 1 for verifiablePresentation" + assert "harbour:verifiablePresentation" in shape_block, ( + f"{ev_type} shape missing harbour:verifiablePresentation" + ) + assert "sh:minCount 1" in shape_block, ( + f"{ev_type} shape missing sh:minCount 1 for verifiablePresentation" + ) # --------------------------------------------------------------------------- @@ -323,9 +323,9 @@ def test_shacl_has_domain_shapes(self): "harbour_gx:NaturalPerson", ] for shape in expected_shapes: - assert ( - f"{shape} a sh:NodeShape" in content - ), f"Missing SHACL NodeShape for {shape}" + assert f"{shape} a sh:NodeShape" in content, ( + f"Missing SHACL NodeShape for {shape}" + ) def test_credential_shapes_have_required_properties(self): """Concrete credential shapes must require validFrom and credentialStatus.""" @@ -341,12 +341,12 @@ def test_credential_shapes_have_required_properties(self): if next_shape == -1: next_shape = len(content) shape_block = content[shape_start:next_shape] - assert ( - "cred:validFrom" in shape_block - ), f"{cred_type} shape missing cred:validFrom" - assert ( - "cred:credentialStatus" in shape_block - ), f"{cred_type} shape missing cred:credentialStatus" + assert "cred:validFrom" in shape_block, ( + f"{cred_type} shape missing cred:validFrom" + ) + assert "cred:credentialStatus" in shape_block, ( + f"{cred_type} shape missing cred:credentialStatus" + ) def test_person_credential_shapes_require_evidence(self): """LegalPersonCredential and NaturalPersonCredential must require evidence.""" @@ -359,6 +359,6 @@ def test_person_credential_shapes_require_evidence(self): if next_shape == -1: next_shape = len(content) shape_block = content[shape_start:next_shape] - assert ( - "cred:evidence" in shape_block - ), f"{cred_type} shape missing cred:evidence" + assert "cred:evidence" in shape_block, ( + f"{cred_type} shape missing cred:evidence" + ) diff --git a/tests/python/harbour/test_delegation.py b/tests/python/harbour/test_delegation.py index 809d9bc..77e0482 100644 --- a/tests/python/harbour/test_delegation.py +++ b/tests/python/harbour/test_delegation.py @@ -21,6 +21,7 @@ from unittest.mock import patch import pytest + from harbour.delegation import ( ACTION_LABELS, ACTION_TYPE, @@ -339,9 +340,9 @@ def test_compute_hash_sensitive_to_all_fields(self): for change in variations: modified = {**base, **change} modified_tx = TransactionData(**modified) - assert ( - modified_tx.compute_hash() != base_hash - ), f"Hash unchanged for {change}" + assert modified_tx.compute_hash() != base_hash, ( + f"Hash unchanged for {change}" + ) class TestSharedVectors: diff --git a/tests/python/harbour/test_kb_jwt.py b/tests/python/harbour/test_kb_jwt.py index 93447a0..c49b527 100644 --- a/tests/python/harbour/test_kb_jwt.py +++ b/tests/python/harbour/test_kb_jwt.py @@ -1,6 +1,7 @@ """Tests for KB-JWT creation and verification with transaction_data support.""" import pytest + from harbour.kb_jwt import create_kb_jwt, verify_kb_jwt from harbour.keys import ( generate_p256_keypair, diff --git a/tests/python/harbour/test_keys.py b/tests/python/harbour/test_keys.py index 1513ff8..6dea01f 100644 --- a/tests/python/harbour/test_keys.py +++ b/tests/python/harbour/test_keys.py @@ -11,6 +11,7 @@ Ed25519PublicKey, ) from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + from harbour.keys import ( generate_ed25519_keypair, generate_p256_keypair, diff --git a/tests/python/harbour/test_sd_jwt.py b/tests/python/harbour/test_sd_jwt.py index cff9562..9b3b1b4 100644 --- a/tests/python/harbour/test_sd_jwt.py +++ b/tests/python/harbour/test_sd_jwt.py @@ -1,6 +1,7 @@ """Tests for SD-JWT-VC issuance and verification.""" import pytest + from harbour.keys import ( generate_p256_keypair, p256_public_key_to_jwk, diff --git a/tests/python/harbour/test_sd_jwt_vp.py b/tests/python/harbour/test_sd_jwt_vp.py index 196d76f..c948d96 100644 --- a/tests/python/harbour/test_sd_jwt_vp.py +++ b/tests/python/harbour/test_sd_jwt_vp.py @@ -5,6 +5,8 @@ import secrets import pytest +from joserfc import jws + from harbour._crypto import import_private_key as _import_private_key from harbour.delegation import ( TransactionData, @@ -15,7 +17,6 @@ from harbour.sd_jwt import issue_sd_jwt_vc from harbour.sd_jwt_vp import issue_sd_jwt_vp, verify_sd_jwt_vp from harbour.verifier import VerificationError -from joserfc import jws def _decode_jwt_payload(token: str) -> dict: diff --git a/tests/python/harbour/test_tamper.py b/tests/python/harbour/test_tamper.py index 58723fc..6ae4d96 100644 --- a/tests/python/harbour/test_tamper.py +++ b/tests/python/harbour/test_tamper.py @@ -4,6 +4,7 @@ import json import pytest + from harbour.signer import sign_vc_jose from harbour.verifier import VerificationError, verify_vc_jose @@ -15,9 +16,9 @@ def test_tamper_payload(sample_vc, p256_private_key, p256_public_key): # Decode payload, tamper, re-encode payload = json.loads(base64.urlsafe_b64decode(parts[1] + "==")) - payload["credentialSubject"][ - "id" - ] = "did:ethr:0x14a34:0x81c6d42b1781bb3bb7a280f564d66ec9d41beace" + payload["credentialSubject"]["id"] = ( + "did:ethr:0x14a34:0x81c6d42b1781bb3bb7a280f564d66ec9d41beace" + ) tampered_payload = ( base64.urlsafe_b64encode( json.dumps(payload, ensure_ascii=False).encode("utf-8") diff --git a/tests/python/harbour/test_verify.py b/tests/python/harbour/test_verify.py index e6fa7e4..409bda7 100644 --- a/tests/python/harbour/test_verify.py +++ b/tests/python/harbour/test_verify.py @@ -1,6 +1,7 @@ """Tests for VC-JOSE-COSE verification.""" import pytest + from harbour.keys import generate_p256_keypair from harbour.signer import sign_vc_jose, sign_vp_jose from harbour.verifier import VerificationError, verify_vc_jose, verify_vp_jose diff --git a/tests/python/harbour/test_x509.py b/tests/python/harbour/test_x509.py index 11ffe24..faced83 100644 --- a/tests/python/harbour/test_x509.py +++ b/tests/python/harbour/test_x509.py @@ -4,6 +4,7 @@ import pytest from cryptography import x509 + from harbour.keys import generate_ed25519_keypair, generate_p256_keypair from harbour.x509 import ( cert_to_x5c, From 36c0d3988ee0c64c3da490910c7524153c6d89ae Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Wed, 11 Mar 2026 19:58:12 +0100 Subject: [PATCH 25/78] fix(generator): add OWL equivalence patches for class_uri bridging - Add _patch_owl_equivalences() to post-process OWL ontology - Bridge class_uri IRIs to OWL class name IRIs with rdfs:subClassOf - Add owl:equivalentClass for OWL reasoners - Fixes RDFS inference for downstream SHACL validators that use class_uri URIs (from JSON-LD context) rather than OWL class IRIs - Add if __name__ guard for direct execution Signed-off-by: Carlo van Driesten --- src/python/harbour/generate_artifacts.py | 65 ++++++++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/src/python/harbour/generate_artifacts.py b/src/python/harbour/generate_artifacts.py index f0bdd5b..89e0c3b 100644 --- a/src/python/harbour/generate_artifacts.py +++ b/src/python/harbour/generate_artifacts.py @@ -20,7 +20,7 @@ from linkml.generators.owlgen import OwlSchemaGenerator from linkml.generators.shaclgen import ShaclGenerator as _BaseShaclGenerator from linkml_runtime.linkml_model.meta import SlotDefinition -from rdflib import Namespace +from rdflib import OWL, Namespace, URIRef REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent LINKML_DIR = REPO_ROOT / "linkml" @@ -118,9 +118,17 @@ def main() -> None: owl_gen = OwlSchemaGenerator( schema, mergeimports=False, importmap=importmap, base_dir=base_dir ) - (out_dir / f"{domain}.owl.ttl").write_text( - owl_gen.serialize(), encoding="utf-8" - ) + owl_text = owl_gen.serialize() + + # Post-process OWL: add owl:equivalentClass triples for classes + # whose class_uri differs from the default OWL class IRI. + # LinkML generates OWL class IRIs as default_prefix + ClassName, + # but class_uri (used for SHACL targetClass and JSON-LD context) + # may differ. Without equivalence, RDFS inference cannot chain + # the subclass hierarchy through class_uri URIs. + owl_text = _patch_owl_equivalences(owl_gen, owl_text) + + (out_dir / f"{domain}.owl.ttl").write_text(owl_text, encoding="utf-8") shacl_gen = HarbourShaclGenerator( schema, importmap=importmap, base_dir=base_dir @@ -148,5 +156,54 @@ def main() -> None: print(f"\nDone: {ARTIFACTS_DIR}/") +def _patch_owl_equivalences(owl_gen: OwlSchemaGenerator, owl_text: str) -> str: + """Add rdfs:subClassOf triples where class_uri differs from the + default OWL class IRI (default_prefix + ClassName). + + LinkML generates OWL using default_prefix + class_name as the class IRI, + but class_uri (which controls SHACL targetClass and JSON-LD type mapping) + can be set to a different URI. Downstream validators that rely on RDFS + inference need the subclass chain to be reachable from class_uri URIs. + + For each class where class_uri != owl_uri, we copy all rdfs:subClassOf + triples from the owl_uri class to the class_uri URI. This ensures RDFS + inference (which doesn't understand owl:equivalentClass) can resolve + the type hierarchy via class_uri URIs used in instance data. + """ + from rdflib import RDFS, Graph + + sv = owl_gen.schemaview + schema = sv.schema + default_pfx = schema.default_prefix or "" + pfx_map = {p.prefix_prefix: p.prefix_reference for p in schema.prefixes.values()} + default_ns = pfx_map.get(default_pfx, "") + + equivalences: list[tuple[str, str]] = [] + for cls_name, cls_def in sv.all_classes().items(): + if cls_def.class_uri: + class_uri_str = sv.get_uri(cls_def, expand=True) + owl_uri = f"{default_ns}{cls_name}" + if class_uri_str and class_uri_str != owl_uri: + equivalences.append((owl_uri, class_uri_str)) + + if not equivalences: + return owl_text + + g = Graph() + g.parse(data=owl_text, format="turtle") + for owl_uri, class_uri_str in equivalences: + owl_ref = URIRef(owl_uri) + cu_ref = URIRef(class_uri_str) + if (owl_ref, None, None) in g: + # Copy rdfs:subClassOf triples from owl_uri to class_uri + for _, _, parent in g.triples((owl_ref, RDFS.subClassOf, None)): + if isinstance(parent, URIRef): + g.add((cu_ref, RDFS.subClassOf, parent)) + # Also add equivalence for OWL reasoners + g.add((owl_ref, OWL.equivalentClass, cu_ref)) + + return g.serialize(format="turtle") + + if __name__ == "__main__": main() From dd5c652be7587b2b9559901b907fd909385c37c5 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Wed, 11 Mar 2026 20:12:02 +0100 Subject: [PATCH 26/78] fix(ci): update OMB submodule to HTTPS URLs Signed-off-by: Carlo van Driesten --- submodules/ontology-management-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index 9d3e1b7..ccf14ee 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit 9d3e1b7809cccbe1d4e19ba47c67ee326e4bf868 +Subproject commit ccf14eea7c3a2ea1281d281230c48b25dc58b6b6 From f0cae06d1a1d8ee9f7f6d561b59b0271fb2240f0 Mon Sep 17 00:00:00 2001 From: felix hoops <9974641+flhps@users.noreply.github.com> Date: Thu, 12 Mar 2026 15:05:52 +0100 Subject: [PATCH 27/78] docs: add DID identity system overview Signed-off-by: felix hoops <9974641+flhps@users.noreply.github.com> --- docs/did-identity-system.md | 124 ++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 docs/did-identity-system.md diff --git a/docs/did-identity-system.md b/docs/did-identity-system.md new file mode 100644 index 0000000..c6b54e8 --- /dev/null +++ b/docs/did-identity-system.md @@ -0,0 +1,124 @@ +# DID Identity System: did:ethr + P-256 + IdentityController + +## Overview + +Haven uses `did:ethr:eip155:8453` (Base mainnet, [ERC-1056](https://github.com/uport-project/ethr-did-registry)) as the single DID method for all dataspace participants. ERC-1056 is a battle-tested registry (deployed since 2018) with established resolver tooling (ethr-did-resolver, universal resolver). + +The key design challenge: natural participants (admins, users) use standard SSI wallets (with OID4VC interface), not Ethereum wallets. They cannot submit Ethereum transactions directly without complicating UX or severely limiting compatible wallets. This is a limitation of interface protocols and key types. The `IdentityController` contract bridges this gap. + +## Why P-256? + +P-256 (secp256r1, ES256) is the dominant curve in SSI/OIDC ecosystems — hardware security keys (FIDO2/WebAuthn), mobile secure enclaves, and many OID4VC wallets suport P-256 natively. Ethereum wallets use secp256k1, which is incompatible. Rather than requiring participants to hold Ethereum wallets, Haven verifies P-256 signatures on-chain using the **EIP-7212 precompile** (available on Base). + +## ERC-1056 and DID Documents + +ERC-1056 stores DID document data as on-chain events. Resolvers replay these events to construct a DID document. The two relevant operations: + +- `setAttribute(identity, name, value, validity)` — publishes a DID document attribute (e.g., a public key or service endpoint) as an event +- `changeOwner(identity, newOwner)` — transfers control of the DID + +Each Ethereum address implicitly has a DID: `did:ethr:eip155:8453:0x`. By default, the address itself is its own controller. Haven overrides this by calling `changeOwner` to make `IdentityController` the ERC-1056 owner of all managed identities. + +## Address Model + +Managed DID addresses are **deterministic and keyless** — there is no corresponding Ethereum private key: + +| Entity | Address derivation | +| ------------------------ | ----------------------------------------------------------- | +| Trust Anchor (TA) | `address(uint160(keccak256(abi.encode(taAddress, nonce))))` | +| Legal Participant (LP) | same pattern | +| Natural Participant (NP) | same pattern | + +`IdentityController` is set as `owners[addr]` in ERC-1056 for all of these. This means only `IdentityController` can update their DID documents — and it only does so after verifying a valid P-256 signature from an authorized key. + +While the TA (or at least one party of a consortium) must have a full Ethereum account to submit everyone's transactions to the blockchain, the TA also gets a keyless DID to allow easy management by its admins. + +## P-256 Keys in DID Documents + +P-256 public keys are stored in DID documents via `setAttribute` using `JsonWebKey2020` encoding: + +- **Admin keys** (LP/TA admins) → `verificationMethod`, `assertionMethod`, `authentication` (in their NP DID and in their LP/TA DID) — authorize management operations on behalf of the entity +- **NP keys** → `verificationMethod`, `assertionMethod`, `authentication` (in their NP DID) — NPs sign VPs for credential presentation and on-chain authorization + +The contract stores key hashes (`keccak256(qx || qy)`) in its own mapping for efficient on-chain lookup, separate from the DID document attributes. + +## IdentityController: How It Works + +`IdentityController` is a UUPS-upgradeable contract that: + +1. **Owns** TA/LP/NP addresses in ERC-1056 +2. **Stores** authorized P-256 key hashes per DID address +3. **Verifies** P-256 signatures on-chain (EIP-7212) +4. **Translates** verified instructions into ERC-1056 calls + +### Instruction Flow + +NPs never submit Ethereum transactions directly. The flow: + +1. NP constructs an instruction payload (pipe-delimited text, HI1 format) +2. NP signs it with their P-256 key — specifically as the nonce inside a JWT VP (the JWT's nonce claim contains `sha256(instruction)` as a hex string) +3. Anyone (TA, org relay, third party relay) submits `(jwtEvidence[], instruction)` (array because signature threshold can be set >1) to `IdentityController.execute()` +4. Contract verifies: correct nonce, authorized key hashes, valid P-256 JWT signatures +5. Contract dispatches the instruction → calls ERC-1056 + +The relay is permissionless — anyone can submit a valid signed instruction. This ensures no single point of failure and makes the system resistant to censorship. + +### Replay Protection + +Each DID has a sequential `nonces[did]` counter stored in the contract. The instruction includes the current nonce value; the contract rejects any instruction with a mismatched nonce and increments it on success. + +### M-of-N Multisig + +Each DID has a configurable threshold (`thresholds[did]`). `execute()` requires at least `threshold` distinct authorized P-256 signatures in the evidence array. Threshold 0 means the identity is deactivated. This can be used by NPs, but is meant to provide a more resilient identity to large LPs. + +### Supported Instructions + +| Instruction | Effect | +| ---------------- | -------------------------------------------------------------- | +| `SetAttr` | `registry.setAttribute(...)` — publish DID document attribute | +| `RevokeAttr` | `registry.revokeAttribute(...)` | +| `AddDelegate` | `registry.addDelegate(...)` — add a delegate on-chain key | +| `RevokeDelegate` | `registry.revokeDelegate(...)` | +| `AddKey` | add P-256 key hash to controller key set | +| `RemoveKey` | remove P-256 key hash (blocked if it would undercut threshold) | +| `SetThreshold` | update M-of-N threshold | +| `Deactivate` | `registry.changeOwner(did, address(0))`, threshold → 0 | + +### JWT Evidence Structure + +Each piece of evidence is a P-256-signed JWT. The contract reconstructs the JWT message on-chain from caller-supplied parts: + +```text +msgHash = sha256(base64url(header) + "." + base64url(prefix + sha256Hex(instruction) + suffix)) +``` + +The `sha256Hex(instruction)` is the nonce embedded in the JWT payload. This ties the JWT signature cryptographically to the specific instruction being executed — the P-256 signature provably covers the instruction content. + +## Bootstrap + +A new DID is bootstrapped via `bootstrapIdentityFull(salt, adminQx, adminQy)` (admin in the sense of controller): + +1. Deploys a `DIDHandover` contract via CREATE2 (deterministic address derived from `keccak256(msg.sender || salt)`) +2. `DIDHandover` constructor automatically calls `registry.changeOwner(self, identityController)` — transfers ERC-1056 ownership +3. `IdentityController` records the first admin P-256 key hash and sets threshold to 1 +4. `IdentityController` also sets the admin key as `verificationMethod`, `assertionMethod`, `authentication` to the DID document (ERC-1056) + +The resulting DID address is the `DIDHandover` contract address. It has no private key; only `IdentityController` can act on it. + +## Summary of Relationships + +```text +P-256 key (SSI wallet) + │ signs JWT VP (nonce = sha256(instruction)) + ▼ +IdentityController.execute(evs[], instruction) + │ verifies P-256 sig on-chain (EIP-7212) + │ checks keyHash ∈ controllerKeys[did] + │ checks nonce, threshold + ▼ +EthereumDIDRegistry (ERC-1056) + │ emits attribute/delegate events + ▼ +DID document (did:ethr:eip155:8453:0x) + resolved by ethr-did-resolver +``` From ec61c964c3e199f7aa55ef8a468feec5895bff31 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Thu, 12 Mar 2026 15:45:05 +0100 Subject: [PATCH 28/78] fix(did): align Harbour Base resolver modeling - model JsonWebKey verification methods explicitly in LinkML with publicKeyJwk - align did:ethr docs and examples with the Base resolver and IdentityController profile - update the workflow docs and bump ontology-management-base to the merged validation fixes Signed-off-by: Carlo van Driesten --- .github/copilot-instructions.md | 8 +- AGENTS.md | 8 +- CLAUDE.md | 5 +- docs/decisions/004-key-management.md | 6 +- docs/decisions/005-did-ethr-migration.md | 14 ++- docs/guide/delegated-signing.md | 51 +++++--- docs/index.md | 1 + docs/specs/delegation-challenge-encoding.md | 2 +- docs/specs/did-method-evaluation.md | 109 +++++++++++------- docs/specs/references/did-ethr-method-spec.md | 28 ++--- examples/README.md | 23 ++-- examples/did-ethr/README.md | 44 ++++--- .../did-ethr/harbour-signing-service.did.json | 27 ++--- .../did-ethr/harbour-trust-anchor.did.json | 24 ++-- ...6d7ea-27ef-416f-abf8-9cb634884e66.did.json | 21 ++-- ...e8400-e29b-41d4-a716-446655440000.did.json | 21 ++-- linkml/harbour-core-credential.yaml | 84 +++++++++++--- linkml/harbour-gx-credential.yaml | 10 +- submodules/ontology-management-base | 2 +- 19 files changed, 296 insertions(+), 192 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index a48a990..3ae4730 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -103,7 +103,10 @@ git commit -s -S -m "feat(harbour): add KB-JWT support" ## Preparing Commits and Pull Requests -When instructed to prepare a commit or PR, **do not commit directly**. Instead: +When instructed to prepare a commit or PR, default to preparing the `.playground` +files first. After **explicit human confirmation in the current session**, the +agent may directly create the signed commit, push the branch, and open the PR +using the prepared `.playground` content. Otherwise: 1. Create files in the `.playground/` directory (already in `.gitignore`) 2. Generate two markdown files: @@ -112,7 +115,8 @@ When instructed to prepare a commit or PR, **do not commit directly**. Instead: The human operator will review these files and either: -- Use them to manually commit/push and create a PR, or +- Use them to manually commit/push and create a PR, +- Ask the agent to perform the signed commit/push/PR flow directly after explicit confirmation, or - Use automated tooling with signed commits (`git commit -s -S`) ## Common Mistakes to Avoid diff --git a/AGENTS.md b/AGENTS.md index 90d6d87..e912d71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,10 @@ git commit -s -S -m "feat(harbour): add KB-JWT verification" ### Preparing Commits and Pull Requests -When instructed to prepare a commit or PR, **do not commit directly**. Instead: +When instructed to prepare a commit or PR, default to preparing the `.playground` +files first. After **explicit human confirmation in the current session**, the +agent may directly create the signed commit, push the branch, and open the PR +using the prepared `.playground` content. Otherwise: 1. Create the `.playground/` directory (already in `.gitignore`) 2. Generate two markdown files: @@ -79,7 +82,8 @@ When instructed to prepare a commit or PR, **do not commit directly**. Instead: The human operator will review these files and either: -- Use them to manually commit/push and create a PR, or +- Use them to manually commit/push and create a PR, +- Ask the agent to perform the signed commit/push/PR flow directly after explicit confirmation, or - Use automated tooling with signed commits (`git commit -s -S`) ### Commit Message Format diff --git a/CLAUDE.md b/CLAUDE.md index 4088b9b..b162ddc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -162,7 +162,10 @@ git commit -s -S -m "feat(harbour): add KB-JWT support" ## Change Documentation -When instructed to prepare a commit or PR, **do not commit directly**. Create these files in `.playground/` (gitignored) for human review: +When instructed to prepare a commit or PR, default to updating these files in +`.playground/` (gitignored) first. After explicit human confirmation in the +current session, the agent may use them to create the signed commit, push the +branch, and open the PR directly. Otherwise, keep them for human review: | File | Purpose | |------|---------| diff --git a/docs/decisions/004-key-management.md b/docs/decisions/004-key-management.md index 1ac10e2..21a36b9 100644 --- a/docs/decisions/004-key-management.md +++ b/docs/decisions/004-key-management.md @@ -73,7 +73,7 @@ Three mechanisms, serving different ecosystems: | Method | Ecosystem | JOSE Header | Example | |--------|-----------|-------------|---------| | **X.509 chain** | EUDI | `x5c` | Certificate chain in JWT header | -| **did:ethr** | Gaia-X | `kid` | `did:ethr:0x14a34:
#delegate-1` | +| **did:ethr** | Gaia-X | `kid` | `did:ethr:0x14a34:
#controller` | | **did:key** | Testing | `kid` | `did:key:zDn...#zDn...` | **X.509 (EUDI mandatory):** @@ -85,8 +85,8 @@ Three mechanisms, serving different ecosystems: **did:ethr (Gaia-X):** -- Resolves to DID Document at well-known URL with KERI key history -- DID Document contains JWK public key(s) +- Resolves through the Base contract + resolver stack +- Signer DID documents expose JWK public key(s), with the primary key at `#controller` - Used for all Harbour identities (infrastructure, organizations, users) - Gaia-X GXDCH uses X.509 certificates as trust anchors for DIDs diff --git a/docs/decisions/005-did-ethr-migration.md b/docs/decisions/005-did-ethr-migration.md index 17b9585..4246093 100644 --- a/docs/decisions/005-did-ethr-migration.md +++ b/docs/decisions/005-did-ethr-migration.md @@ -35,16 +35,18 @@ deployed on **Base** (Coinbase L2 rollup). | **Chain ID** | Testnet: 84532 (`0x14a34`), Mainnet: 8453 (`0x2105`) | | **Contract** | ERC-1056 EthereumDIDRegistry (standard or custom with P-256 support) | | **P-256 keys** | Registered as on-chain attributes via `setAttribute()` | -| **Controller** | Smart contract manages identity ownership | +| **Controller** | Resolved signer DIDs expose a local P-256 `#controller` key | | **DID format** | `did:ethr::` | ### DID document structure -The EthereumDIDRegistry resolves DID documents from on-chain events: +The EthereumDIDRegistry and project-specific resolver derive DID documents from +on-chain state: -- `DIDOwnerChanged` → `controller` field -- `DIDDelegateChanged` → `verificationMethod` entries (delegates) -- `DIDAttributeChanged` → `verificationMethod` entries (attributes like P-256 keys) +- signer DIDs expose a local P-256 `#controller` method for ES256 signing +- optional secondary P-256 keys appear as `#delegate-N` +- non-signing resource DIDs may instead use the root DID Core `controller` + property to point at the owning DID ## Consequences @@ -73,7 +75,7 @@ The EthereumDIDRegistry resolves DID documents from on-chain events: ## References - [ERC-1056: Ethereum Lightweight Identity](https://eips.ethereum.org/EIPS/eip-1056) -- [did:ethr Method Specification](https://github.com/decentralized-identity/ethr-did-resolver/blob/master/doc/did-method-spec.md) +- [did:ethr Method Specification](../specs/references/did-ethr-method-spec.md) - [Base Documentation](https://docs.base.org/) - [ADR-001: VC Securing Mechanism](001-vc-securing-mechanism.md) - [ADR-004: Key Management](004-key-management.md) diff --git a/docs/guide/delegated-signing.md b/docs/guide/delegated-signing.md index 0546eed..5ad7c68 100644 --- a/docs/guide/delegated-signing.md +++ b/docs/guide/delegated-signing.md @@ -75,26 +75,41 @@ The user needs a Harbour credential (e.g., `NaturalPersonCredential`) issued as ### 2. DID Document -The user's `did:ethr` DID document must contain a verification method with their P-256 public key (the same key as in their `did:jwk` wallet): +The user's `did:ethr` DID document must expose the same P-256 public key as a +local `#controller` verification method: ```json { - "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/jwk/v1"], + "@context": [ + "https://www.w3.org/ns/did/v1", + { + "JsonWebKey": "https://w3id.org/security#JsonWebKey", + "publicKeyJwk": { + "@id": "https://w3id.org/security#publicKeyJwk", + "@type": "@json" + } + } + ], "id": "did:ethr:0x14a34:0x26e4...16c9", - "controller": "did:ethr:0x14a34:0x26e4...16c9", - "verificationMethod": [{ - "id": "did:ethr:0x14a34:0x26e4...16c9#key-1", - "type": "JsonWebKey2020", - "controller": "did:ethr:0x14a34:0x26e4...16c9", - "publicKeyJwk": { - "kty": "EC", - "crv": "P-256", - "x": "...", - "y": "..." + "verificationMethod": [ + { + "id": "did:ethr:0x14a34:0x26e4...16c9#controller", + "type": "JsonWebKey", + "controller": "did:ethr:0x14a34:0x26e4...16c9", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "...", + "y": "..." + } } - }], - "authentication": ["#key-1"], - "assertionMethod": ["#key-1"] + ], + "authentication": [ + "did:ethr:0x14a34:0x26e4...16c9#controller" + ], + "assertionMethod": [ + "did:ethr:0x14a34:0x26e4...16c9#controller" + ] } ``` @@ -104,8 +119,8 @@ See [`examples/did-ethr/`](../../examples/did-ethr/) for complete DID documents. This repository verifies signatures and hash bindings, but it does **not** host or publish DID documents. -- Integrators must publish DID documents at the correct HTTPS location for the chosen method (`did:ethr` or `did:ethr`). -- Integrators must run DID resolution and pass the resolved holder key into `verify_sd_jwt_vp(...)`. +- Integrators must run the appropriate `did:ethr` resolver for their Base deployment. +- Integrators must pass the resolved holder key into `verify_sd_jwt_vp(...)`. - Repository examples now use `did:ethr` identifiers for person subjects. See `examples/did-ethr/` for static example DID documents used by `examples/*.json`. - Naming policy in examples: - All identifiers use UUID-based path segments (no real names or organization names in DID paths). @@ -114,7 +129,7 @@ Current integration hooks and TODOs: - `issue_sd_jwt_vp(..., holder_did=...)` allows the wallet DID to be embedded in the consent VP. - `verify_sd_jwt_vp(..., holder_public_key=...)` accepts the DID-resolved public key from your resolver stack. -- TODO: Add optional resolver callback adapters for `did:ethr` so verification can resolve keys in-process. +- TODO: Add optional resolver callback adapters for `did:ethr` so verification can resolve custom P-256 controller keys in-process. ## OID4VP Transaction Data diff --git a/docs/index.md b/docs/index.md index 2c62531..a735336 100644 --- a/docs/index.md +++ b/docs/index.md @@ -69,3 +69,4 @@ npm install @reachhaven/harbour-credentials - [Quick Start](getting-started/quickstart.md) — Get up and running - [CLI Reference](cli/index.md) — Command-line tools - [API Reference](api/python/index.md) — Python and TypeScript APIs +- [DID Method Evaluation](specs/did-method-evaluation.md) — `did:ethr` modeling notes and local reference specs diff --git a/docs/specs/delegation-challenge-encoding.md b/docs/specs/delegation-challenge-encoding.md index ae259c3..01698d3 100644 --- a/docs/specs/delegation-challenge-encoding.md +++ b/docs/specs/delegation-challenge-encoding.md @@ -214,7 +214,7 @@ The delegated consent is captured as `evidence` in a Verifiable Credential or di "proofPurpose": "authentication", "challenge": "da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52", "domain": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", - "verificationMethod": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#key-1", + "verificationMethod": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#controller", "created": "2026-02-24T12:00:05Z", "proofValue": "z5vgFc..." } diff --git a/docs/specs/did-method-evaluation.md b/docs/specs/did-method-evaluation.md index 340a091..8b6f7fb 100644 --- a/docs/specs/did-method-evaluation.md +++ b/docs/specs/did-method-evaluation.md @@ -1,19 +1,19 @@ # DID Method Evaluation: did:ethr -> **Decision**: Harbour uses `did:ethr` (ERC-1056 / EthereumDIDRegistry) on **Base** (L2 rollup) -> as its primary DID method, replacing the previously evaluated `did:web` and `did:webs`. +> **Decision**: Harbour uses `did:ethr` on **Base** (L2 rollup) as its primary +> DID method, replacing the previously evaluated `did:web` and `did:webs`. > -> This document summarizes the evaluation and rationale. +> This document summarizes the rationale and the current Harbour resolver profile. ## Glossary | Term | Definition | |------|-----------| -| **did:ethr** | DID method anchored on Ethereum-compatible blockchains via ERC-1056 (EthereumDIDRegistry). Supports key rotation, delegate management, and attribute registration through on-chain events. | -| **did:web** | *(Superseded)* DID method that uses web domains for identifier resolution. DID documents hosted as JSON files at well-known URLs. | +| **did:ethr** | DID method anchored on Ethereum-compatible blockchains via ERC-1056-style contract state. | +| **did:web** | *(Superseded)* DID method that uses web domains for identifier resolution. | | **did:webs** | *(Superseded)* Extension of did:web that adds KERI for cryptographically verifiable key history. | | **did:key** | Ephemeral DID method encoding a single public key. Used for testing and wallet-generated identifiers. | -| **ERC-1056** | Ethereum Improvement Proposal defining the EthereumDIDRegistry smart contract. | +| **ERC-1056** | Ethereum Improvement Proposal defining the EthereumDIDRegistry smart contract pattern. | | **Base** | Coinbase L2 rollup on Ethereum, providing low-cost transactions with Ethereum security. | ## Why did:ethr? @@ -22,24 +22,24 @@ | Feature | did:web | did:webs | did:ethr | |---------|---------|----------|----------| -| Resolution | HTTPS fetch | HTTPS + KERI | On-chain events | -| Key rotation | Replace file | KEL append | On-chain `changeOwner` / `setAttribute` | -| Revocation | Delete document | KEL revocation | `revokeDelegate` / `changeOwner(0x0)` | -| Offline verification | ❌ | ✅ (via KEL) | ✅ (via cached events) | -| Infrastructure | Web server | Web server + KERI node | EVM node (public RPCs available) | -| Decentralisation | ❌ (DNS/TLS) | Partial (KERI witnesses) | ✅ (blockchain) | -| P-256 support | Native | Native | Via `setAttribute()` (delegate keys) | -| Wallet support | Broad | Limited (KERI wallets) | Broad (ethers.js, MetaMask, etc.) | +| Resolution | HTTPS fetch | HTTPS + KERI | Base contract state + resolver | +| Key rotation | Replace file | KEL append | On-chain updates | +| Revocation | Delete document | KEL revocation | Contract state / resolver policy | +| Offline verification | ❌ | ✅ (via KEL) | ✅ (via cached events/state) | +| Infrastructure | Web server | Web server + KERI node | EVM node + resolver | +| Decentralisation | ❌ (DNS/TLS) | Partial (KERI witnesses) | ✅ (blockchain anchored) | +| P-256 support | Native | Native | First-class in Harbour profile | +| Wallet support | Broad | Limited (KERI wallets) | Broad for ES256 consumers | | Cost per operation | Free (hosting) | Free (hosting) | Gas fees (low on Base L2) | ### Key Advantages -1. **No web server dependency** — DID documents are resolved from on-chain events, not HTTPS endpoints -2. **Cryptographic key history** — All key changes are permanently recorded on-chain +1. **No web server dependency** — DID documents are resolved from Base state, not HTTPS endpoints +2. **Cryptographic key history** — Key changes are anchored on-chain 3. **True decentralisation** — No reliance on DNS or TLS certificate authorities -4. **P-256 key registration** — Custom smart contract registers P-256 keys as on-chain attributes -5. **Low cost on Base** — L2 gas fees are orders of magnitude cheaper than Ethereum mainnet -6. **Broad ecosystem support** — `ethr-did-resolver` available for JS/TS, Python resolver libraries available +4. **P-256-first examples** — Resolver output surfaces P-256 controller keys directly +5. **Low cost on Base** — L2 gas fees are much lower than Ethereum mainnet +6. **Composability** — Service/program DIDs can be modelled as externally controlled resources ## DID Format @@ -53,35 +53,52 @@ did:ethr:0x14a34:0x71C7656EC7ab88b098defB751B7401B5f6d8976F did:ethr:0x2105:0x71C7656EC7ab88b098defB751B7401B5f6d8976F ``` +Depending on resolver/tooling, production DIDs may also be rendered with an +explicit EIP-155 network segment such as `did:ethr:eip155:8453:
`. +The checked-in Harbour examples keep the hexadecimal chain-ID form because that +matches the current example fixtures and downstream validation setup. + ## DID Document Resolution -The `ethr-did-resolver` reconstructs DID documents by reading `DIDAttributeChanged`, -`DIDDelegateChanged`, and `DIDOwnerChanged` events from the EthereumDIDRegistry contract. +Harbour examples assume a project-specific resolver profile on top of Base: + +- **signer DIDs** expose a local P-256 `JsonWebKey` as `#controller` +- **optional secondary keys** appear as `#delegate-N` +- **resource DIDs** (programs, services) may use the root DID Core `controller` + property to point at an owning DID instead of exposing a local signing key + +These JSON examples represent the **resolved verifier-facing DID document**, not +the raw ERC-1056 owner state. In the Harbour identity architecture, managed DID +addresses are deterministic and keyless, while an on-chain `IdentityController` +contract owns the ERC-1056 identities, verifies relayed P-256-signed +instructions, and publishes the DID document attributes that the resolver turns +into the JSON-LD surface shown here. + +Example signer DID output: ```json { "@context": [ "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/suites/secp256k1recovery-2020/v2" + { + "JsonWebKey": "https://w3id.org/security#JsonWebKey", + "publicKeyJwk": { + "@id": "https://w3id.org/security#publicKeyJwk", + "@type": "@json" + } + } ], "id": "did:ethr:0x14a34:0x71C7656EC7ab88b098defB751B7401B5f6d8976F", - "controller": "did:ethr:0x14a34:0xC0FFEEbabe000000000000000000000000000001", "verificationMethod": [ { "id": "...#controller", - "type": "EcdsaSecp256k1RecoveryMethod2020", - "controller": "...", - "blockchainAccountId": "eip155:84532:0x71C7656EC7ab88b098defB751B7401B5f6d8976F" - }, - { - "id": "...#delegate-1", "type": "JsonWebKey", "controller": "...", "publicKeyJwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." } } ], - "authentication": ["...#controller", "...#delegate-1"], - "assertionMethod": ["...#controller", "...#delegate-1"] + "authentication": ["...#controller"], + "assertionMethod": ["...#controller"] } ``` @@ -90,17 +107,22 @@ The `ethr-did-resolver` reconstructs DID documents by reading `DIDAttributeChang | Context | DID Method | kid Format | |---------|-----------|------------| | **EUDI** | X.509 | `x5c` header (no kid) | -| **Gaia-X** | `did:ethr` | `did:ethr:0x14a34:
#delegate-1` | +| **Gaia-X / Harbour** | `did:ethr` | `did:ethr:0x14a34:
#controller` | | **Testing** | `did:key` | `did:key:zDn...#zDn...` | ### Identity Architecture | Role | DID Pattern | Key Usage | |------|-------------|-----------| -| Signing Service | `did:ethr:0x14a34:` | `#delegate-1` (assertionMethod), `#delegate-2` (capabilityDelegation) | -| Trust Anchor | `did:ethr:0x14a34:` | `#delegate-1` (assertionMethod) | -| Participants | `did:ethr:0x14a34:` | `#delegate-1` (assertionMethod) | -| Users | `did:ethr:0x14a34:` | `#delegate-1` (assertionMethod) | +| Signing Service | `did:ethr:0x14a34:` | `#controller` (assertionMethod), `#delegate-1` (capabilityDelegation) | +| Trust Anchor | `did:ethr:0x14a34:` | `#controller` (assertionMethod) | +| Participants | `did:ethr:0x14a34:` | `#controller` (assertionMethod) | +| Users | `did:ethr:0x14a34:` | `#controller` (assertionMethod) | + +Natural participants use standard SSI wallets and sign authorization material +with P-256 keys; they do not need Ethereum private keys. A relay submits the +resulting instructions on-chain, and `IdentityController` enforces nonce and +threshold checks before updating ERC-1056 state. ## Network Configuration @@ -118,19 +140,20 @@ The `ethr-did-resolver` reconstructs DID documents by reading `DIDAttributeChang The migration from did:web/did:webs to did:ethr involves: -1. **Deriving Ethereum addresses** from existing P-256 key material -2. **Registering P-256 keys** as on-chain attributes via `setAttribute()` -3. **Updating all credential examples** to use `did:ethr` identifiers -4. **Deploying EthereumDIDRegistry** (or using existing deployment) on Base +1. **Anchoring identifiers on Base** +2. **Registering P-256 keys** so the resolver can surface them in the DID document +3. **Updating credential examples** to use `did:ethr` identifiers and `#controller` kids +4. **Deploying resolver support** for the Harbour Base profile See `examples/did-ethr/` for migrated DID document examples. ## References -- [did:ethr Method Specification](https://github.com/decentralized-identity/ethr-did-resolver/blob/master/doc/did-method-spec.md) (DIF) +- [did:ethr Method Specification](references/did-ethr-method-spec.md) (local reference copy) - [ERC-1056: Ethereum Lightweight Identity](https://eips.ethereum.org/EIPS/eip-1056) -- [ethr-did-resolver](https://github.com/decentralized-identity/ethr-did-resolver) (JavaScript) +- [ethr-did-resolver](https://github.com/decentralized-identity/ethr-did-resolver) (baseline reference) - [Base Documentation](https://docs.base.org/) +- `docs/did-identity-system.md` — Harbour-specific on-chain identity architecture overview ### Archived Specifications @@ -138,4 +161,4 @@ These specifications are retained for historical reference but are no longer the - `did-web-method.txt` — did:web specification (W3C CCG) *(superseded)* - `did-webs-spec.md` — did:webs specification (ToIP) *(superseded)* -- `did-ethr-method-spec.md` — did:ethr method specification (active) +- `references/did-ethr-method-spec.md` — did:ethr method specification (active reference baseline) diff --git a/docs/specs/references/did-ethr-method-spec.md b/docs/specs/references/did-ethr-method-spec.md index fed9c5b..230dbc6 100644 --- a/docs/specs/references/did-ethr-method-spec.md +++ b/docs/specs/references/did-ethr-method-spec.md @@ -430,21 +430,21 @@ A `DIDAttributeChanged` event for the account `0xf3beac30c498d9e26865f34fcaa57db With the exception of `#controller` and `#controllerKey`, the `id` properties that appear throughout the DID document MUST be stable across updates. This means that the same key material will be referenced by the same ID after an update. -- Attribute or delegate changes that result in `verificationMethod` entries MUST set the `id` +* Attribute or delegate changes that result in `verificationMethod` entries MUST set the `id` `${did}#delegate-${eventIndex}`. -- Attributes that result in `service` entries MUST set the `id` to `${did}#service-${eventIndex}` +* Attributes that result in `service` entries MUST set the `id` to `${did}#service-${eventIndex}` where `eventIndex` is the index of the event that modifies that section of the DID document. **Example** -- add key => `#delegate-1` is added -- add another key => `#delegate-2` is added -- add delegate => `#delegate-3` is added -- add service => `#service-1` ia added -- revoke first key => `#delegate-1` gets removed from the DID document; `#delegate-2` and `#delegte-3` remain. -- add another delegate => `#delegate-5` is added (earlier revocation is counted as an event) -- first delegate expires => `delegate-3` is removed, `#delegate-5` remains intact +* add key => `#delegate-1` is added +* add another key => `#delegate-2` is added +* add delegate => `#delegate-3` is added +* add service => `#service-1` ia added +* revoke first key => `#delegate-1` gets removed from the DID document; `#delegate-2` and `#delegte-3` remain. +* add another delegate => `#delegate-5` is added (earlier revocation is counted as an event) +* first delegate expires => `delegate-3` is removed, `#delegate-5` remains intact ### Update @@ -501,8 +501,8 @@ The `resolve` method returns an object with the following properties: `didDocume When resolving a DID document that has had updates, the latest update MUST be listed in the `didDocumentMetadata`. -- `versionId` MUST be the block number of the latest update. -- `updated` MUST be the ISO date string of the block time of the latest update (without sub-second resolution). +* `versionId` MUST be the block number of the latest update. +* `updated` MUST be the ISO date string of the block time of the latest update (without sub-second resolution). Example: @@ -525,7 +525,7 @@ Example: } ``` -## Resolving DID URIs with query parameters +## Resolving DID URIs with query parameters. ### `versionId` query string parameter @@ -539,8 +539,8 @@ Only ERC1056 events prior to or contained in this block number are to be conside If there are any events after that block that mutate the DID, the earliest of them SHOULD be used to populate the properties of the `didDocumentMetadata`: -- `nextVersionId` MUST be the block number of the next update to the DID document. -- `nextUpdate` MUST be the ISO date string of the block time of the next update (without sub-second resolution). +* `nextVersionId` MUST be the block number of the next update to the DID document. +* `nextUpdate` MUST be the ISO date string of the block time of the next update (without sub-second resolution). In case the DID has had updates prior to or included in the `versionId` block number, the `updated` and `versionId` properties of the `didDocumentMetadata` MUST correspond to the latest block prior to the `versionId` query string param. diff --git a/examples/README.md b/examples/README.md index 78b05e2..b970a54 100644 --- a/examples/README.md +++ b/examples/README.md @@ -65,34 +65,37 @@ Anchor's DID document. See [`gaiax/trust-anchor-credential.json`](gaiax/trust-an ## Actors and Identities -Every actor has a `did:ethr` identity (ERC-1056-backed, long-lived). Users also have -a `did:jwk` wallet key (P-256) in the Altme wallet. When a user requests a +Every actor has a `did:ethr` identity anchored on Base. Users also have a +`did:jwk` wallet key (P-256) in the Altme wallet. When a user requests a credential, the authorizing party presents a VP to the Signing Service. Harbour -then creates the `did:ethr` identifier and embeds **the same P-256 public key** -from the wallet into the new `did:ethr` DID document. +then creates the `did:ethr` identifier and exposes **the same P-256 public +key** from the wallet as the local `#controller` verification method in the +resolved DID document. | Actor | Role | Identity (`did:ethr`) | DID Document | |-------|------|-----------------------|--------------| | **Harbour Trust Anchor** | Root of trust, authorizes orgs | `did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3` | [`harbour-trust-anchor.did.json`](did-ethr/harbour-trust-anchor.did.json) | -| **Harbour Signing Service** | Issues ALL credentials (`#key-1`), signs delegated txns (`#key-2`) | `did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697` | [`harbour-signing-service.did.json`](did-ethr/harbour-signing-service.did.json) | +| **Harbour Signing Service** | Issues ALL credentials (`#controller`), signs delegated txns (`#delegate-1`) | `did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697` | [`harbour-signing-service.did.json`](did-ethr/harbour-signing-service.did.json) | | **Example Corporation GmbH** | Legal person (organization) | `did:ethr:0x14a34:0xf7ef...dab` | [`legal-person-0aa6d7ea-...did.json`](did-ethr/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json) | | **Alice Smith** | Natural person (employee) | `did:ethr:0x14a34:0x26e4...16c9` | [`natural-person-550e8400-...did.json`](did-ethr/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json) | | **ENVITED Marketplace** | Data marketplace (external) | `did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c` | — | -> **Privacy note**: All `did:ethr` identifiers use UUID path segments — never +> **Privacy note**: All `did:ethr` identifiers use opaque chain/address segments — never > real names or organization names. This prevents DID IRIs from leaking identity > information at the public layer. ### Signing Service Key Roles -The Signing Service DID document contains two verification methods: +The Signing Service DID document contains two P-256 verification methods: | Key | Relationship | Purpose | |-----|-------------|---------| -| `#key-1` | `assertionMethod` | Credential issuance (signs all VCs) | -| `#key-2` | `capabilityDelegation` | Delegated transaction signing | +| `#controller` | `authentication`, `assertionMethod` | Primary controller and credential issuance key | +| `#delegate-1` | `authentication`, `capabilityDelegation` | Delegated transaction signing | -Both keys are listed under `authentication`. +Signer DID documents in these Harbour examples expose local P-256 controller +keys directly. They do not model a separate synthetic secp256k1 recovery method +in the example JSON output. --- diff --git a/examples/did-ethr/README.md b/examples/did-ethr/README.md index 0e0b73d..b40c631 100644 --- a/examples/did-ethr/README.md +++ b/examples/did-ethr/README.md @@ -1,7 +1,16 @@ # did:ethr DID Documents -Example DID documents for the Harbour identity ecosystem, using `did:ethr` (ERC-1056) -on **Base** (chain ID `84532` / `0x14a34` for testnet). +Example DID documents for the Harbour identity ecosystem, using `did:ethr` on +**Base** (chain ID `84532` / `0x14a34` for testnet). + +These examples assume the Harbour/Base resolver exposes signer-controlled +P-256 keys directly in the resolved DID document. + +Behind that resolved view, Harbour uses deterministic keyless DID addresses and +an on-chain `IdentityController` contract that owns the ERC-1056 identities, +verifies relayed P-256-signed instructions, and publishes DID attributes. The +JSON files in this directory show the **resolved DID document surface consumed +by wallets and verifiers**, not the raw registry ownership metadata. ## Entities @@ -14,27 +23,25 @@ on **Base** (chain ID `84532` / `0x14a34` for testnet). ## DID Document Structure -Each document follows the `did:ethr` resolved format: - -- **`@context`** includes `secp256k1recovery-2020/v2` for the controller VM -- **`controller`** points to the smart contract that manages identity ownership -- **`#controller`** verification method: `EcdsaSecp256k1RecoveryMethod2020` with `blockchainAccountId` -- **`#delegate-N`** verification methods: P-256 `JsonWebKey` keys registered as on-chain attributes - -## Controller - -All identities are governed by a smart contract controller: +Each signer DID document follows the Harbour example profile: -```text -did:ethr:0x14a34:0xC0FFEEbabe000000000000000000000000000001 -``` +- **`#controller`** is a local P-256 `JsonWebKey` and the primary ES256 signing key +- **`#delegate-N`** entries are optional additional P-256 keys +- **`#service-N`** entries represent DID services when present -This is a placeholder address — the actual contract will be deployed to Base. +For Harbour, this means the example JSON output models the signing keys that +matter to wallets and verifiers, while any chain anchoring or recovery state is +left to the Base contract and resolver implementation. ## Key Management -P-256 keys (ES256) are the primary signing keys, registered on-chain via `setAttribute()`. -The secp256k1 controller key provides blockchain-native identity ownership. +- Trust Anchor, Legal Person, and Natural Person use `#controller` for issuance or consent flows +- The Signing Service uses `#controller` for issuing credentials and `#delegate-1` + for delegated transaction signing +- All example signatures use ES256 over P-256 keys +- Natural persons approve actions with wallet-held P-256 keys; a relay can + submit the resulting signed instructions on-chain without requiring users to + hold Ethereum private keys ## Usage @@ -43,3 +50,4 @@ These DID documents are referenced by: - `examples/*.json` — Credential examples (issuer, subject, holder) - `examples/gaiax/*.json` — Gaia-X specific credential examples - `tests/` — Test fixtures and assertions +- `docs/did-identity-system.md` — detailed Harbour on-chain identity overview diff --git a/examples/did-ethr/harbour-signing-service.did.json b/examples/did-ethr/harbour-signing-service.did.json index ae98ceb..9406d7f 100644 --- a/examples/did-ethr/harbour-signing-service.did.json +++ b/examples/did-ethr/harbour-signing-service.did.json @@ -1,19 +1,18 @@ { "@context": [ "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/suites/secp256k1recovery-2020/v2" + { + "JsonWebKey": "https://w3id.org/security#JsonWebKey", + "publicKeyJwk": { + "@id": "https://w3id.org/security#publicKeyJwk", + "@type": "@json" + } + } ], "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", - "controller": "did:ethr:0x14a34:0xC0FFEEbabe000000000000000000000000000001", "verificationMethod": [ { "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#controller", - "type": "EcdsaSecp256k1RecoveryMethod2020", - "controller": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", - "blockchainAccountId": "eip155:84532:0x9c2f52ea812629d0d35b2786ae26633d03a8c697" - }, - { - "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-1", "type": "JsonWebKey", "controller": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "publicKeyJwk": { @@ -24,7 +23,7 @@ } }, { - "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-2", + "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-1", "type": "JsonWebKey", "controller": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "publicKeyJwk": { @@ -37,16 +36,12 @@ ], "authentication": [ "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#controller", - "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-1", - "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-2" + "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-1" ], "assertionMethod": [ - "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#controller", - "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-1", - "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-2" + "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#controller" ], "capabilityDelegation": [ - "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-1", - "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-2" + "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-1" ] } diff --git a/examples/did-ethr/harbour-trust-anchor.did.json b/examples/did-ethr/harbour-trust-anchor.did.json index a5e20bc..ccee23c 100644 --- a/examples/did-ethr/harbour-trust-anchor.did.json +++ b/examples/did-ethr/harbour-trust-anchor.did.json @@ -1,19 +1,19 @@ { "@context": [ "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/suites/secp256k1recovery-2020/v2" + "https://w3id.org/reachhaven/harbour/credentials/v1/", + { + "JsonWebKey": "https://w3id.org/security#JsonWebKey", + "publicKeyJwk": { + "@id": "https://w3id.org/security#publicKeyJwk", + "@type": "@json" + } + } ], "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", - "controller": "did:ethr:0x14a34:0xC0FFEEbabe000000000000000000000000000001", "verificationMethod": [ { "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#controller", - "type": "EcdsaSecp256k1RecoveryMethod2020", - "controller": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", - "blockchainAccountId": "eip155:84532:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3" - }, - { - "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#delegate-1", "type": "JsonWebKey", "controller": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "publicKeyJwk": { @@ -25,16 +25,14 @@ } ], "authentication": [ - "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#controller", - "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#delegate-1" + "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#controller" ], "assertionMethod": [ - "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#controller", - "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#delegate-1" + "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#controller" ], "service": [ { - "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#linked-credential", + "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#service-1", "type": "harbour:LinkedCredentialService", "serviceEndpoint": "https://reachhaven.com/.well-known/credentials/trust-anchor.jwt" } diff --git a/examples/did-ethr/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json b/examples/did-ethr/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json index 62530b2..38fed22 100644 --- a/examples/did-ethr/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json +++ b/examples/did-ethr/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json @@ -1,19 +1,18 @@ { "@context": [ "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/suites/secp256k1recovery-2020/v2" + { + "JsonWebKey": "https://w3id.org/security#JsonWebKey", + "publicKeyJwk": { + "@id": "https://w3id.org/security#publicKeyJwk", + "@type": "@json" + } + } ], "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", - "controller": "did:ethr:0x14a34:0xC0FFEEbabe000000000000000000000000000001", "verificationMethod": [ { "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab#controller", - "type": "EcdsaSecp256k1RecoveryMethod2020", - "controller": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", - "blockchainAccountId": "eip155:84532:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" - }, - { - "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab#delegate-1", "type": "JsonWebKey", "controller": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", "publicKeyJwk": { @@ -25,11 +24,9 @@ } ], "authentication": [ - "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab#controller", - "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab#delegate-1" + "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab#controller" ], "assertionMethod": [ - "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab#controller", - "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab#delegate-1" + "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab#controller" ] } diff --git a/examples/did-ethr/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json b/examples/did-ethr/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json index 2c445e8..11c618d 100644 --- a/examples/did-ethr/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json +++ b/examples/did-ethr/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json @@ -1,19 +1,18 @@ { "@context": [ "https://www.w3.org/ns/did/v1", - "https://w3id.org/security/suites/secp256k1recovery-2020/v2" + { + "JsonWebKey": "https://w3id.org/security#JsonWebKey", + "publicKeyJwk": { + "@id": "https://w3id.org/security#publicKeyJwk", + "@type": "@json" + } + } ], "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", - "controller": "did:ethr:0x14a34:0xC0FFEEbabe000000000000000000000000000001", "verificationMethod": [ { "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#controller", - "type": "EcdsaSecp256k1RecoveryMethod2020", - "controller": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", - "blockchainAccountId": "eip155:84532:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9" - }, - { - "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#delegate-1", "type": "JsonWebKey", "controller": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", "publicKeyJwk": { @@ -25,11 +24,9 @@ } ], "authentication": [ - "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#controller", - "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#delegate-1" + "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#controller" ], "assertionMethod": [ - "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#controller", - "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#delegate-1" + "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#controller" ] } diff --git a/linkml/harbour-core-credential.yaml b/linkml/harbour-core-credential.yaml index 8310289..63e0591 100644 --- a/linkml/harbour-core-credential.yaml +++ b/linkml/harbour-core-credential.yaml @@ -49,7 +49,10 @@ description: > prefixes: linkml: https://w3id.org/linkml/ harbour: https://w3id.org/reachhaven/harbour/credentials/v1/ + sec: https://w3id.org/security# + sdo: https://schema.org/ xsd: http://www.w3.org/2001/XMLSchema# + rdf: http://www.w3.org/1999/02/22-rdf-syntax-ns# cs: https://www.w3.org/ns/credentials/status# cred: https://www.w3.org/2018/credentials# didcore: https://www.w3.org/ns/did# @@ -61,6 +64,13 @@ imports: - linkml:types - ./w3c-vc +types: + JsonLiteral: + uri: rdf:JSON + base: str + description: > + Structured JSON content serialized as an RDF JSON literal. + slots: # --- Identity Slots --- # [VCDM2] §4.4 — "id" is OPTIONAL on VC/VP/credentialSubject. @@ -87,8 +97,27 @@ slots: # entity authorized to make changes to the DID document. # In practice always a DID (did:ethr:..., did:key:..., etc.). controller: - slot_uri: didcore:controller - range: uri + slot_uri: sec:controller + range: Any + + # [DID-CORE JSON-LD] did/v1 expands authentication to the security vocabulary + # and represents values as IRI references to verification methods. + authentication: + slot_uri: sec:authenticationMethod + range: Any + + # [DID-CORE JSON-LD] did/v1 expands assertionMethod to the security + # vocabulary and represents values as IRI references to verification methods. + assertionMethod: + slot_uri: sec:assertionMethod + range: Any + + # [DID-CORE] §5.3.1 — publicKeyJwk carries verification method key material + # encoded as a JSON Web Key (RFC 7517). In JSON-LD it is represented as + # an rdf:JSON literal when the term is typed with @json. + publicKeyJwk: + slot_uri: sec:publicKeyJwk + range: JsonLiteral # [DID-CORE] §5.4 — serviceEndpoint can be a URI, map, or set. # Each service entry MUST have id, type, and serviceEndpoint. @@ -149,7 +178,7 @@ slots: # url, email, contactPoint use different gx URIs, so they are defined # as inline attributes on OrganizationEndpoint / ContactPoint below. contactType: - slot_uri: https://schema.org/contactType + slot_uri: sdo:contactType range: string # [VC-JOSE-COSE] §6.1 — media types: application/vp+jwt, application/vp+sd-jwt. @@ -206,20 +235,28 @@ classes: slots: - controller attributes: + authentication: + slot_uri: sec:authenticationMethod + multivalued: true + range: Any + assertionMethod: + slot_uri: sec:assertionMethod + multivalued: true + range: Any # [DID-CORE] §5.4 — service is OPTIONAL, each entry MUST have id, type, # and serviceEndpoint. service values MUST be unique. service: slot_uri: didcore:service multivalued: true inlined: true - range: ServiceUnion + range: Any # [DID-CORE] §5.3.1 — verificationMethod entries MUST have id, type, # controller, and key material (publicKeyJwk or publicKeyMultibase). # Harbour models a subset (id, controller, blockchainAccountId). verificationMethod: - slot_uri: didcore:verificationMethod + slot_uri: sec:verificationMethod multivalued: true - range: VerificationMethod + range: Any # ========================================== # 2. SERVICES @@ -251,41 +288,42 @@ classes: - serviceEndpoint slot_usage: serviceEndpoint: - range: uri + range: Any description: > HTTPS URL where the self-signed credential (VC-JOSE-COSE JWT) can - be fetched. Typically a .well-known path on the Trust Anchor's domain. + be fetched. In DID JSON-LD this is commonly emitted as an IRI node + via did:serviceEndpoint rather than an xsd:anyURI literal. # Uses schema.org Organization vocabulary for interoperability. OrganizationEndpoint: - class_uri: https://schema.org/Organization + class_uri: sdo:Organization attributes: name: description: A human-readable name for the organization. - slot_uri: https://schema.org/name + slot_uri: sdo:name range: string url: description: A URL associated with the organization. - slot_uri: https://schema.org/url + slot_uri: sdo:url range: uri description: description: A human-readable description of the organization. - slot_uri: https://schema.org/description + slot_uri: sdo:description range: string contactPoint: description: A contact point for the organization. - slot_uri: https://schema.org/contactPoint + slot_uri: sdo:contactPoint range: ContactPoint inlined: true ContactPoint: - class_uri: https://schema.org/ContactPoint + class_uri: sdo:ContactPoint slots: - contactType attributes: email: description: An email address. - slot_uri: https://schema.org/email + slot_uri: sdo:email range: string # Harbour-specific: CRSet revocation registry service endpoint. @@ -520,3 +558,19 @@ classes: blockchainAccountId: slot_uri: harbour:blockchainAccountId range: string + + JsonWebKey: + is_a: VerificationMethod + class_uri: sec:JsonWebKey + description: > + [VC-JOSE-COSE] §4.2 — verification method encoded as a JSON Web Key. + The Harbour and SimpulseID did:ethr examples use this concrete method + type for local P-256 controller and delegate keys. + slots: + - publicKeyJwk + slot_usage: + publicKeyJwk: + required: true + description: > + [DID-CORE] §5.3.1 — key material for this verification method in + JSON Web Key form. diff --git a/linkml/harbour-gx-credential.yaml b/linkml/harbour-gx-credential.yaml index 980779e..97f6562 100644 --- a/linkml/harbour-gx-credential.yaml +++ b/linkml/harbour-gx-credential.yaml @@ -48,6 +48,7 @@ prefixes: linkml: https://w3id.org/linkml/ harbour: https://w3id.org/reachhaven/harbour/credentials/v1/ harbour_gx: https://w3id.org/reachhaven/harbour/gaiax-domain/v1/ + sdo: https://schema.org/ xsd: http://www.w3.org/2001/XMLSchema# cred: https://www.w3.org/2018/credentials# gx: https://w3id.org/gaia-x/development# @@ -157,23 +158,22 @@ classes: - email slot_usage: email: - slot_uri: https://schema.org/email + slot_uri: sdo:email address: slot_uri: gx:address attributes: # [SCHEMA-ORG] — https://schema.org/givenName givenName: description: First name / given name of the natural person. - slot_uri: https://schema.org/givenName + slot_uri: sdo:givenName range: string # [SCHEMA-ORG] — https://schema.org/familyName familyName: description: Last name / family name of the natural person. - slot_uri: https://schema.org/familyName + slot_uri: sdo:familyName range: string # [SCHEMA-ORG] — https://schema.org/memberOf memberOf: description: Organization (LegalPerson) the natural person belongs to. - slot_uri: https://schema.org/memberOf + slot_uri: sdo:memberOf range: uri - diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index ccf14ee..7f4d960 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit ccf14eea7c3a2ea1281d281230c48b25dc58b6b6 +Subproject commit 7f4d960441171fb2eba2ee7bcf6d98cae3a751d6 From 59094b3949a4cc42d96be3387f1fecfb249fecc4 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Thu, 12 Mar 2026 17:35:35 +0100 Subject: [PATCH 29/78] feat(workflow): group story and validation commands - replace legacy make aliases with grouped command centers - verify signed story outputs with the new Python helper - update the nested ontology-management-base pointer Signed-off-by: Carlo van Driesten --- .github/copilot-instructions.md | 12 +- AGENTS.md | 14 +- CLAUDE.md | 6 +- Makefile | 250 ++++++++++++++++-- README.md | 25 +- docs/contributing.md | 10 +- .../002-dual-runtime-architecture.md | 2 +- docs/getting-started/installation.md | 2 +- .../credentials/verify_signed_examples.py | 131 +++++++++ submodules/ontology-management-base | 2 +- 10 files changed, 392 insertions(+), 62 deletions(-) create mode 100644 src/python/credentials/verify_signed_examples.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3ae4730..9711606 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,26 +7,26 @@ This repository contains cryptographic libraries for signing and verifying verif ```bash # Install dev dependencies make setup -make install-dev +make install dev # Run all tests (Python + TypeScript) -make test-all +make test full # Run Python tests only make test # Run TypeScript tests only -make test-ts +make test ts # Build TypeScript -make build-ts +make build # Lint and format make lint make format # Run with coverage -make test-cov +make test cov ``` ## Instruction Files @@ -125,4 +125,4 @@ The human operator will review these files and either: - ❌ **Don't forget CLI** — All Python modules need `main()` with `--help` - ❌ **Don't break parity** — Keep Python and TypeScript APIs consistent - ❌ **Don't commit without signing** — Always use `-s -S` -- ❌ **Don't skip tests** — Run `make test-all` before committing +- ❌ **Don't skip tests** — Run `make test full` before committing diff --git a/AGENTS.md b/AGENTS.md index e912d71..1be5c40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,23 +34,23 @@ tests/ ```bash # Install dev dependencies make setup -make install-dev +make install dev # Run all tests (Python + TypeScript) -make test-all +make test full # Run Python tests only make test # Run TypeScript tests only -make test-ts +make test ts # Lint and format make lint make format # Build TypeScript -make build-ts +make build ``` ## Git Commit & Pull Request Policy @@ -119,8 +119,8 @@ Brief description of the changes. ## Testing - [ ] Python tests pass (`make test`) -- [ ] TypeScript tests pass (`make test-ts`) -- [ ] All tests pass (`make test-all`) +- [ ] TypeScript tests pass (`make test ts`) +- [ ] All tests pass (`make test full`) ## Related Issues @@ -142,7 +142,7 @@ Closes #42 - TypeScript 5.x with strict mode - Use async/await for crypto operations - Export types alongside functions -- Run `make lint-ts` before committing +- Run `make lint ts` before committing ## Module CLI Requirements diff --git a/CLAUDE.md b/CLAUDE.md index b162ddc..314cff3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,13 +16,13 @@ make setup source .venv/bin/activate # Run all tests (Python + TypeScript) -make test-all +make test full # Run Python tests only make test # Run TypeScript tests only -make test-ts +make test ts # Run a single Python test file PYTHONPATH=src/python:$PYTHONPATH pytest tests/python/harbour/test_sign.py -v @@ -40,7 +40,7 @@ cd src/typescript/harbour && yarn vitest run --config vitest.config.ts ../../../ PYTHONPATH=src/python:$PYTHONPATH pytest tests/interop/test_cross_runtime.py -v # Build TypeScript -make build-ts +make build # Lint and format make lint diff --git a/Makefile b/Makefile index 61465dd..9bc6c29 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,15 @@ # Harbour Credentials Makefile # ============================ -.PHONY: setup install install-dev submodule-setup ts-bootstrap generate validate validate-shacl lint lint-md format format-md test test-cov test-ts test-interop build-ts lint-ts test-all all clean help +.PHONY: setup install submodule-setup ts-bootstrap generate validate lint format test build story all clean help \ + _help_general _help_install _help_validate _help_lint _help_format _help_test _help_story _help_build \ + _install_default _install_dev \ + _validate_default _validate_shacl \ + _lint_default _lint_md _lint_ts \ + _format_default _format_md \ + _test_default _test_cov _test_ts _test_interop _test_all \ + _story_default _story_sign _story_verify \ + _build_ts TS_DIR := src/typescript/harbour OMB_SUBMODULE_DIR := submodules/ontology-management-base @@ -61,42 +69,96 @@ endef # LinkML schema files LINKML_SCHEMAS := $(wildcard linkml/*.yaml) DOMAINS := harbour-core-credential harbour-gx-credential +HARBOUR_EXAMPLE_FILES := $(wildcard examples/*.json) $(wildcard examples/gaiax/*.json) +GROUPED_COMMANDS := install validate lint format test story build +PRIMARY_GOAL := $(firstword $(MAKECMDGOALS)) -# Default target +# Grouped command mode: treat trailing goals as subcommands +ifneq ($(filter $(PRIMARY_GOAL),$(GROUPED_COMMANDS)),) +help: + @: + +%: + @: +else help: + @$(MAKE) --no-print-directory _help_general +endif + +# Default target +_help_general: @echo "Harbour Credentials - Available Commands" @echo "" @echo "Installation:" @echo " make setup - Create venv, install dev dependencies, setup ontology submodule, and bootstrap TypeScript" @echo " make install - Install package (user mode)" - @echo " make install-dev - Install with dev dependencies + pre-commit" + @echo " make install help - Show install subcommands" @echo " make ts-bootstrap - Enable corepack and install TypeScript dependencies" @echo "" @echo "Artifacts:" @echo " make generate - Generate OWL/SHACL/context from LinkML" @echo " make validate - Validate credentials against SHACL shapes" - @echo " make validate-shacl - Run SHACL conformance on examples (via ontology-management-base)" + @echo " make validate help - Show validate subcommands" @echo "" @echo "Linting:" - @echo " make lint - Run pre-commit checks (Python + Markdown)" - @echo " make lint-md - Lint Markdown files with markdownlint-cli2" - @echo " make lint-ts - Run TypeScript linting" - @echo " make format - Format Python code with ruff" - @echo " make format-md - Auto-fix Markdown lint violations" + @echo " make lint - Run pre-commit checks (Python + Markdown)" + @echo " make lint help - Show lint subcommands" + @echo " make format - Format Python code with ruff" + @echo " make format help - Show format subcommands" @echo "" @echo "Testing:" @echo " make test - Run Python pytest suite" - @echo " make test-ts - Run TypeScript vitest suite" - @echo " make test-interop - Run cross-runtime interop tests (Python + TypeScript)" - @echo " make test-all - Run Python tests + SHACL conformance + TypeScript tests" - @echo " make test-cov - Run Python tests with coverage report" + @echo " make test help - Show test subcommands" + @echo " make story - Generate, sign, verify, and SHACL-validate example storylines" + @echo " make story help - Show story subcommands" @echo "" @echo "TypeScript:" - @echo " make build-ts - Build TypeScript package" + @echo " make build - Build TypeScript package" + @echo " make build help - Show build subcommands" @echo "" @echo "Cleaning:" @echo " make clean - Remove build artifacts and caches" +_help_install: + @echo "Install subcommands:" + @echo " make install - Install package (user mode)" + @echo " make install dev - Install with dev dependencies + pre-commit" + +_help_validate: + @echo "Validate subcommands:" + @echo " make validate - Run structural validation tests" + @echo " make validate shacl - Run SHACL conformance on examples via OMB" + +_help_lint: + @echo "Lint subcommands:" + @echo " make lint - Run pre-commit checks" + @echo " make lint md - Lint Markdown files with markdownlint-cli2" + @echo " make lint ts - Run TypeScript linting" + +_help_format: + @echo "Format subcommands:" + @echo " make format - Format Python code with ruff" + @echo " make format md - Auto-fix Markdown lint violations" + +_help_test: + @echo "Test subcommands:" + @echo " make test - Run Python pytest suite" + @echo " make test cov - Run Python tests with coverage" + @echo " make test ts - Run TypeScript vitest suite" + @echo " make test interop - Run cross-runtime interop tests" + @echo " make test full - Run Python + SHACL + TypeScript tests" + +_help_story: + @echo "Story subcommands:" + @echo " make story - Generate, sign, verify, and SHACL-validate examples" + @echo " make story sign - Write ignored signed example artifacts under examples/**/signed/" + @echo " make story verify - Verify the signed example artifacts with the real verifier" + +_help_build: + @echo "Build subcommands:" + @echo " make build - Build the TypeScript package" + @echo " make build ts - Build the TypeScript package" + # Create virtual environment and install dependencies setup: @echo "Setting up development environment..." @@ -156,6 +218,21 @@ ts-bootstrap: # Install package (user mode) install: + @set -- $(filter-out $@,$(MAKECMDGOALS)); \ + subcommand="$${1:-default}"; \ + if [ "$$#" -gt 1 ]; then \ + echo "ERROR: Too many subcommands for 'make install': $(filter-out $@,$(MAKECMDGOALS))"; \ + echo "Run 'make install help' for available options."; \ + exit 1; \ + fi; \ + case "$$subcommand" in \ + default) $(MAKE) --no-print-directory _install_default ;; \ + dev) $(MAKE) --no-print-directory _install_dev ;; \ + help) $(MAKE) --no-print-directory _help_install ;; \ + *) echo "ERROR: Unknown install subcommand '$$subcommand'"; echo "Run 'make install help' for available options."; exit 1 ;; \ + esac + +_install_default: @echo "Installing package in editable mode..." ifndef CI @$(MAKE) --no-print-directory $(VENV)/bin/python3 @@ -164,7 +241,7 @@ endif @echo "OK: Package installation complete" # Install with dev dependencies (works in CI without venv creation) -install-dev: +_install_dev: @echo "Installing development dependencies..." ifndef CI @$(MAKE) --no-print-directory $(VENV)/bin/python3 @@ -186,20 +263,35 @@ generate: # Validate credentials against generated SHACL shapes and JSON-LD syntax validate: + @set -- $(filter-out $@,$(MAKECMDGOALS)); \ + subcommand="$${1:-default}"; \ + if [ "$$#" -gt 1 ]; then \ + echo "ERROR: Too many subcommands for 'make validate': $(filter-out $@,$(MAKECMDGOALS))"; \ + echo "Run 'make validate help' for available options."; \ + exit 1; \ + fi; \ + case "$$subcommand" in \ + default) $(MAKE) --no-print-directory _validate_default ;; \ + shacl) $(MAKE) --no-print-directory _validate_shacl ;; \ + help) $(MAKE) --no-print-directory _help_validate ;; \ + *) echo "ERROR: Unknown validate subcommand '$$subcommand'"; echo "Run 'make validate help' for available options."; exit 1 ;; \ + esac + +_validate_default: $(call check_dev_setup) @echo "Validating harbour credentials..." @PYTHONPATH=src/python:$$PYTHONPATH $(PYTEST) tests/python/credentials/test_validation.py -v @echo "OK: Validation complete" # Validate example credentials against SHACL shapes via ontology-management-base -validate-shacl: +_validate_shacl: $(call check_dev_setup) @echo "Running SHACL data conformance check on examples..." @cd $(OMB_SUBMODULE_DIR) && \ tmp_output=$$(mktemp) && \ $(PYTHON_ABS) -m src.tools.validators.validation_suite \ --run check-data-conformance \ - --data-paths ../../examples/ ../../examples/gaiax/ ../../tests/validation-probe/ontology-loading-probe.json \ + --data-paths $(addprefix ../../,$(HARBOUR_EXAMPLE_FILES)) ../../examples/did-ethr/ ../../tests/validation-probe/ontology-loading-probe.json \ --artifacts ../../artifacts > $$tmp_output 2>&1 ; \ status=$$? ; \ cat $$tmp_output ; \ @@ -223,19 +315,50 @@ validate-shacl: # Run pre-commit hooks on all files lint: + @set -- $(filter-out $@,$(MAKECMDGOALS)); \ + subcommand="$${1:-default}"; \ + if [ "$$#" -gt 1 ]; then \ + echo "ERROR: Too many subcommands for 'make lint': $(filter-out $@,$(MAKECMDGOALS))"; \ + echo "Run 'make lint help' for available options."; \ + exit 1; \ + fi; \ + case "$$subcommand" in \ + default) $(MAKE) --no-print-directory _lint_default ;; \ + md) $(MAKE) --no-print-directory _lint_md ;; \ + ts) $(MAKE) --no-print-directory _lint_ts ;; \ + help) $(MAKE) --no-print-directory _help_lint ;; \ + *) echo "ERROR: Unknown lint subcommand '$$subcommand'"; echo "Run 'make lint help' for available options."; exit 1 ;; \ + esac + +_lint_default: $(call check_dev_setup) @echo "Running pre-commit checks..." @$(PYTHON) -m pre_commit run --all-files @echo "OK: Pre-commit checks complete" # Lint Markdown files -lint-md: ## Lint Markdown files with markdownlint-cli2 +_lint_md: ## Lint Markdown files with markdownlint-cli2 @echo "Linting Markdown files..." @npx --yes markdownlint-cli2 @echo "OK: Markdown lint complete" # Auto-format code format: + @set -- $(filter-out $@,$(MAKECMDGOALS)); \ + subcommand="$${1:-default}"; \ + if [ "$$#" -gt 1 ]; then \ + echo "ERROR: Too many subcommands for 'make format': $(filter-out $@,$(MAKECMDGOALS))"; \ + echo "Run 'make format help' for available options."; \ + exit 1; \ + fi; \ + case "$$subcommand" in \ + default) $(MAKE) --no-print-directory _format_default ;; \ + md) $(MAKE) --no-print-directory _format_md ;; \ + help) $(MAKE) --no-print-directory _help_format ;; \ + *) echo "ERROR: Unknown format subcommand '$$subcommand'"; echo "Run 'make format help' for available options."; exit 1 ;; \ + esac + +_format_default: $(call check_dev_setup) @echo "Formatting Python code..." @$(PYTHON) -m ruff format src/python/ tests/ @@ -243,48 +366,117 @@ format: @echo "OK: Python formatting complete" # Auto-fix Markdown lint violations -format-md: ## Auto-fix Markdown lint violations +_format_md: ## Auto-fix Markdown lint violations @echo "Fixing Markdown files..." @npx --yes markdownlint-cli2 --fix @echo "OK: Markdown formatting complete" # Run tests test: + @set -- $(filter-out $@,$(MAKECMDGOALS)); \ + subcommand="$${1:-default}"; \ + if [ "$$#" -gt 1 ]; then \ + echo "ERROR: Too many subcommands for 'make test': $(filter-out $@,$(MAKECMDGOALS))"; \ + echo "Run 'make test help' for available options."; \ + exit 1; \ + fi; \ + case "$$subcommand" in \ + default) $(MAKE) --no-print-directory _test_default ;; \ + cov) $(MAKE) --no-print-directory _test_cov ;; \ + ts) $(MAKE) --no-print-directory _test_ts ;; \ + interop) $(MAKE) --no-print-directory _test_interop ;; \ + full) $(MAKE) --no-print-directory _test_all ;; \ + help) $(MAKE) --no-print-directory _help_test ;; \ + *) echo "ERROR: Unknown test subcommand '$$subcommand'"; echo "Run 'make test help' for available options."; exit 1 ;; \ + esac + +_test_default: $(call check_dev_setup) @echo "Running Python tests..." @PYTHONPATH=src/python:$$PYTHONPATH $(PYTEST) tests/ -v @echo "OK: Python tests complete" # Run tests with coverage -test-cov: +_test_cov: $(call check_dev_setup) @echo "Running Python tests with coverage..." @PYTHONPATH=src/python:$$PYTHONPATH $(PYTEST) tests/ --cov=src/python/harbour --cov=src/python/credentials --cov-report=html --cov-report=term @echo "OK: Coverage run complete" # TypeScript targets -build-ts: +build: + @set -- $(filter-out $@,$(MAKECMDGOALS)); \ + subcommand="$${1:-default}"; \ + if [ "$$#" -gt 1 ]; then \ + echo "ERROR: Too many subcommands for 'make build': $(filter-out $@,$(MAKECMDGOALS))"; \ + echo "Run 'make build help' for available options."; \ + exit 1; \ + fi; \ + case "$$subcommand" in \ + default|ts) $(MAKE) --no-print-directory _build_ts ;; \ + help) $(MAKE) --no-print-directory _help_build ;; \ + *) echo "ERROR: Unknown build subcommand '$$subcommand'"; echo "Run 'make build help' for available options."; exit 1 ;; \ + esac + +_build_ts: @echo "Building TypeScript..." @cd $(TS_DIR) && corepack enable && yarn install && yarn build @echo "OK: TypeScript build complete" -test-ts: +_test_ts: @echo "Running TypeScript tests..." @cd $(TS_DIR) && corepack enable && yarn install && yarn test @echo "OK: TypeScript tests complete" -lint-ts: +_lint_ts: @echo "Linting TypeScript..." @cd $(TS_DIR) && corepack enable && yarn install && yarn lint @echo "OK: TypeScript lint complete" # Cross-runtime interop tests (requires both Python + TypeScript) -test-interop: +_test_interop: $(call check_dev_setup) @echo "Running cross-runtime interop tests..." @PYTHONPATH=src/python:$$PYTHONPATH $(PYTEST) tests/interop/ -v @echo "OK: Interop tests complete" +story: + @set -- $(filter-out $@,$(MAKECMDGOALS)); \ + subcommand="$${1:-default}"; \ + if [ "$$#" -gt 1 ]; then \ + echo "ERROR: Too many subcommands for 'make story': $(filter-out $@,$(MAKECMDGOALS))"; \ + echo "Run 'make story help' for available options."; \ + exit 1; \ + fi; \ + case "$$subcommand" in \ + default) $(MAKE) --no-print-directory _story_default ;; \ + sign) $(MAKE) --no-print-directory _story_sign ;; \ + verify) $(MAKE) --no-print-directory _story_verify ;; \ + help) $(MAKE) --no-print-directory _help_story ;; \ + *) echo "ERROR: Unknown story subcommand '$$subcommand'"; echo "Run 'make story help' for available options."; exit 1 ;; \ + esac + +_story_sign: + $(call check_dev_setup) + @echo "Signing Harbour example storylines..." + @rm -rf examples/signed examples/gaiax/signed + @PYTHONPATH=src/python:$$PYTHONPATH $(PYTHON) -m credentials.example_signer examples/ + @echo "OK: Signed example artifacts written to ignored signed/ directories" + +_story_verify: + $(call check_dev_setup) + @echo "Verifying Harbour signed example storylines..." + @PYTHONPATH=src/python:$$PYTHONPATH $(PYTHON) -m credentials.verify_signed_examples + @echo "OK: Signed Harbour example artifacts verified" + +_story_default: + @echo "Running Harbour storyline (generate + sign + verify + SHACL validate)..." + @$(MAKE) --no-print-directory generate + @$(MAKE) --no-print-directory _story_sign + @$(MAKE) --no-print-directory _story_verify + @$(MAKE) --no-print-directory _validate_shacl + @echo "OK: Harbour storyline complete" + # Compound targets all: @echo "Running default quality pipeline (lint + test)..." @@ -293,12 +485,12 @@ all: @echo "OK: Default quality pipeline complete" # Run all tests (Python + TypeScript) -test-all: +_test_all: @echo "Running all tests (Python + SHACL + TypeScript)..." - @$(MAKE) --no-print-directory build-ts - @$(MAKE) --no-print-directory test - @$(MAKE) --no-print-directory validate-shacl - @$(MAKE) --no-print-directory test-ts + @$(MAKE) --no-print-directory _build_ts + @$(MAKE) --no-print-directory _test_default + @$(MAKE) --no-print-directory _validate_shacl + @$(MAKE) --no-print-directory _test_ts @echo "OK: All tests complete" # Clean generated files diff --git a/README.md b/README.md index c9776a7..a7c337b 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ source .venv/bin/activate > **Note:** The `--recurse-submodules` flag is required to clone the ontology-management-base and w3id.org submodules. > > `make setup` installs Python dev dependencies (`.[dev]`), LinkML, pre-commit hooks, and bootstraps TypeScript dependencies (`corepack enable` + `yarn install` in `src/typescript/harbour`). -> Use `make install-dev` only if you need to refresh an existing Python environment. +> Use `make install dev` only if you need to refresh an existing Python environment. If you already cloned without submodules: @@ -164,10 +164,13 @@ Validate harbour credentials against SHACL shapes using the ontology-management- make generate # Validate examples against SHACL shapes (harbour + gx) -make validate-shacl +make validate shacl # Run structural validation tests make validate + +# See validation subcommands +make validate help ``` ## CLI Usage @@ -253,21 +256,25 @@ artifacts/ # Generated per domain (make generate) # Python tests make test -# TypeScript tests (requires make build-ts first) -make build-ts -make test-ts +# TypeScript tests (requires make build first) +make build +make test ts -# Cross-runtime interop tests (requires make build-ts first) -make test-interop +# Cross-runtime interop tests (requires make build first) +make test interop # Full pipeline: Python + SHACL conformance + TypeScript (builds TS automatically) -make test-all +make test full # Python tests with coverage -make test-cov +make test cov # Lint make lint + +# See grouped subcommands +make test help +make story help ``` ## Documentation diff --git a/docs/contributing.md b/docs/contributing.md index 9386317..a5a2163 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -21,7 +21,7 @@ Thank you for your interest in contributing to Harbour Credentials! 3. **Verify everything works**: ```bash - make test-all + make test full make lint ``` @@ -53,19 +53,19 @@ Thank you for your interest in contributing to Harbour Credentials! - TypeScript 5.x with strict mode - Use `async/await` for crypto operations - Export types alongside functions -- Run `make lint-ts` before committing +- Run `make lint ts` before committing ### Testing ```bash # Run all tests -make test-all +make test full # Python only make test # TypeScript only -make test-ts +make test ts # Single Python test file PYTHONPATH=src/python:$PYTHONPATH pytest tests/python/harbour/test_keys.py -v @@ -113,7 +113,7 @@ git commit -s -S -m "feat(harbour): add feature" ### Before Submitting -- [ ] All tests pass (`make test-all`) +- [ ] All tests pass (`make test full`) - [ ] Linting passes (`make lint`) - [ ] Documentation is updated if needed - [ ] Commit messages follow conventional format diff --git a/docs/decisions/002-dual-runtime-architecture.md b/docs/decisions/002-dual-runtime-architecture.md index 730a5ac..bc9a14d 100644 --- a/docs/decisions/002-dual-runtime-architecture.md +++ b/docs/decisions/002-dual-runtime-architecture.md @@ -78,7 +78,7 @@ jobs: lint: # black, isort, flake8, eslint, prettier test-python: # pytest tests/ test-js: # npm test (vitest or jest) - test-interop: # Cross-runtime verification + interop-tests: # Cross-runtime verification ``` The interop job: diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index b7e4bed..6b75781 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -26,7 +26,7 @@ pip install -e ".[dev]" ```bash make setup -make install-dev +make install dev ``` ## TypeScript diff --git a/src/python/credentials/verify_signed_examples.py b/src/python/credentials/verify_signed_examples.py new file mode 100644 index 0000000..5c298e5 --- /dev/null +++ b/src/python/credentials/verify_signed_examples.py @@ -0,0 +1,131 @@ +"""Verify signed example artifacts generated by ``credentials.example_signer``. + +This script validates the ignored ``examples/signed/`` and +``examples/gaiax/signed/`` output folders using the real Harbour verification +functions. It is intended for end-to-end storyline runs driven by ``make``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from credentials.example_signer import load_test_p256_keypair +from harbour.verifier import verify_vc_jose, verify_vp_jose + + +@dataclass +class VerificationCounts: + """Summary counters for verified example artifacts.""" + + credentials: int = 0 + evidence_presentations: int = 0 + nested_credentials: int = 0 + + +def _find_repo_root() -> Path: + current = Path(__file__).resolve().parent + while current != current.parent: + if (current / ".git").is_dir() or (current / "submodules").is_dir(): + return current + current = current.parent + return Path.cwd() + + +def _discover_signed_dirs(repo_root: Path) -> list[Path]: + candidates = [ + repo_root / "examples" / "signed", + repo_root / "examples" / "gaiax" / "signed", + ] + return [path for path in candidates if path.is_dir()] + + +def _iter_signed_credentials(signed_dir: Path) -> list[Path]: + return sorted( + path for path in signed_dir.glob("*.jwt") if ".evidence-vp." not in path.name + ) + + +def _assert_has_type(payload: dict, expected_type: str, source: Path) -> None: + types = payload.get("type", []) + if isinstance(types, str): + types = [types] + if expected_type not in types: + raise RuntimeError( + f"{source} does not contain expected type {expected_type!r}: {types!r}" + ) + + +def verify_signed_dir(signed_dir: Path, public_key) -> VerificationCounts: + counts = VerificationCounts() + signed_credentials = _iter_signed_credentials(signed_dir) + if not signed_credentials: + raise RuntimeError(f"No signed VC JWTs found in {signed_dir}") + + for jwt_path in signed_credentials: + vc_jwt = jwt_path.read_text(encoding="utf-8").strip() + vc_payload = verify_vc_jose(vc_jwt, public_key) + _assert_has_type(vc_payload, "VerifiableCredential", jwt_path) + counts.credentials += 1 + + evidence_jwt_path = jwt_path.with_name(f"{jwt_path.stem}.evidence-vp.jwt") + if not evidence_jwt_path.exists(): + continue + + vp_jwt = evidence_jwt_path.read_text(encoding="utf-8").strip() + vp_payload = verify_vp_jose(vp_jwt, public_key) + _assert_has_type(vp_payload, "VerifiablePresentation", evidence_jwt_path) + counts.evidence_presentations += 1 + + embedded_vps = [ + evidence.get("verifiablePresentation") + for evidence in vc_payload.get("evidence", []) + if isinstance(evidence, dict) + ] + if vp_jwt not in embedded_vps: + raise RuntimeError( + f"{evidence_jwt_path} was not embedded in {jwt_path} evidence chain" + ) + + for inner in vp_payload.get("verifiableCredential", []): + if isinstance(inner, str) and inner.count(".") == 2: + inner_payload = verify_vc_jose(inner, public_key) + _assert_has_type( + inner_payload, "VerifiableCredential", evidence_jwt_path + ) + counts.nested_credentials += 1 + + return counts + + +def main() -> None: + repo_root = _find_repo_root() + _, public_key = load_test_p256_keypair() + signed_dirs = _discover_signed_dirs(repo_root) + if not signed_dirs: + raise RuntimeError( + "No signed example directories found. Run `make story-sign` first." + ) + + total = VerificationCounts() + for signed_dir in signed_dirs: + counts = verify_signed_dir(signed_dir, public_key) + total.credentials += counts.credentials + total.evidence_presentations += counts.evidence_presentations + total.nested_credentials += counts.nested_credentials + print( + f"Verified {counts.credentials} credential JWT(s), " + f"{counts.evidence_presentations} evidence VP JWT(s), and " + f"{counts.nested_credentials} nested VC JWT(s) in {signed_dir}" + ) + + print( + "Done: " + f"{total.credentials} credential JWT(s), " + f"{total.evidence_presentations} evidence VP JWT(s), " + f"{total.nested_credentials} nested VC JWT(s) verified" + ) + + +if __name__ == "__main__": + main() diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index 7f4d960..15bf33a 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit 7f4d960441171fb2eba2ee7bcf6d98cae3a751d6 +Subproject commit 15bf33ac6050f5cb48144be42cff513d38746458 From d343dda08b7fca79a596c3972e9688204ede3b10 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Thu, 12 Mar 2026 17:48:30 +0100 Subject: [PATCH 30/78] fix(ci): use grouped make commands Signed-off-by: Carlo van Driesten --- .github/workflows/ci.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e43acd4..91c4dec 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: cache: 'pip' - name: Install dependencies - run: make install-dev + run: make install dev - name: Run pre-commit run: make lint @@ -53,7 +53,7 @@ jobs: node-version: "22" - name: Lint TypeScript - run: make lint-ts + run: make lint ts generate-validate: name: Generate & Validate @@ -69,7 +69,7 @@ jobs: cache: 'pip' - name: Install dependencies - run: make install-dev + run: make install dev - name: Install ontology-management-base run: make submodule-setup @@ -81,7 +81,7 @@ jobs: run: make validate - name: Validate examples (SHACL conformance) - run: make validate-shacl + run: make validate shacl test-python: name: Test (Python) @@ -95,7 +95,7 @@ jobs: cache: 'pip' - name: Install dependencies - run: make install-dev + run: make install dev - name: Run tests run: make test @@ -111,7 +111,7 @@ jobs: node-version: "22" - name: Run tests - run: make test-ts + run: make test ts test-interop: name: Cross-Runtime Interop @@ -130,10 +130,10 @@ jobs: node-version: "22" - name: Install Python dependencies - run: make install-dev + run: make install dev - name: Build TypeScript - run: make build-ts + run: make build ts - name: Run interop tests - run: make test-interop + run: make test interop From 843f1eafd7a22e5ac87b327319d8eb363fff290f Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Fri, 13 Mar 2026 22:25:03 +0100 Subject: [PATCH 31/78] feat(linkml): Gaia-X compliance model, delegation schema, spec references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ComplianceCredential abstract base and LegalPersonCredential as the compliance-verified credential type (3 required GX VCs) - Add harbour-core-delegation.yaml with OWL classes for all 5 transaction types (data.purchase, blockchain.transfer, etc.) - Rename harbour_delegate: prefix to harbour.delegate: (dot notation) - Fix schema.org namespace: https → http to match upstream Gaia-X - Add comprehensive spec references ([GX-CD-PA] §5.1, [GX-CD-TA] §8, [GX-CD-LABEL] §10, [SRI], etc.) with local doc paths - Fix schema ids: harbour/credentials/v1 → harbour/core/v1, harbour/gaiax-domain/v1 → harbour/gx/v1 - Add gx-compliance-document-25.10.md spec reference - Update credential-model.md, architecture.md, evidence.md docs - Fix examples: evidence arrays, credentialSubject types, serviceEndpoint, unique DIDs for RDF graph isolation - Regenerate all test vectors for harbour.delegate: canonical hashes - Increase JWT max_payload_length to 65536 for evidence VPs Signed-off-by: Carlo van Driesten --- Makefile | 70 +++- README.md | 2 +- docs/architecture.md | 2 +- docs/guide/delegated-signing.md | 12 +- docs/guide/evidence.md | 21 +- docs/schema/credential-model.md | 196 ++++++---- docs/specs/delegation-challenge-encoding.md | 40 +- docs/specs/references/README.md | 1 + .../gx-architecture-document-25.11.md | 40 +- .../gx-compliance-document-25.10.md | 229 +++++++++++ examples/README.md | 6 +- examples/credential-with-evidence.json | 10 +- examples/credential-with-nested-evidence.json | 10 +- .../did-ethr/harbour-trust-anchor.did.json | 2 +- examples/gaiax/README.md | 48 ++- examples/gaiax/delegated-signing-receipt.json | 16 +- examples/gaiax/gx-legal-person.json | 39 ++ examples/gaiax/gx-registration-number.json | 28 ++ examples/gaiax/gx-terms-and-conditions.json | 18 + .../legal-person-credential-embedded.json | 87 +++++ examples/gaiax/legal-person-credential.json | 66 ++-- examples/gaiax/natural-person-credential.json | 96 ++++- examples/gaiax/participant-vp.json | 182 +++++++++ examples/gaiax/trust-anchor-credential.json | 6 +- ...rticipant Credential VC_(2026)_signed.json | 43 ++ ...articipant Credential VC_(2026)_signed.jwt | 1 + ...oire Participant Credential VP_(2026).json | 95 +++++ ...articipant Credential VP_(2026)_signed.jwt | 1 + linkml/harbour-core-credential.yaml | 22 +- linkml/harbour-core-delegation.yaml | 300 ++++++++++++++ linkml/harbour-gx-credential.yaml | 369 +++++++++++++++--- src/python/credentials/claim_mapping.py | 75 ++-- src/python/harbour/delegation.py | 6 +- src/python/harbour/generate_artifacts.py | 28 +- src/python/harbour/verifier.py | 3 +- src/typescript/harbour/delegation.ts | 6 +- submodules/ontology-management-base | 2 +- tests/fixtures/canonicalization-vectors.json | 48 +-- tests/fixtures/sample-vc.json | 2 +- .../python/credentials/test_claim_mapping.py | 28 +- .../python/credentials/test_example_signer.py | 16 +- tests/python/credentials/test_validation.py | 33 +- tests/python/harbour/test_delegation.py | 58 +-- tests/python/harbour/test_kb_jwt.py | 2 +- tests/python/harbour/test_sd_jwt.py | 2 +- tests/python/harbour/test_sd_jwt_vp.py | 18 +- tests/typescript/harbour/delegation.test.ts | 28 +- tests/typescript/harbour/sd-jwt-vp.test.ts | 10 +- tests/typescript/harbour/sd-jwt.test.ts | 2 +- .../ontology-loading-probe.json | 4 +- 50 files changed, 1971 insertions(+), 458 deletions(-) create mode 100644 docs/specs/references/gx-compliance-document-25.10.md create mode 100644 examples/gaiax/gx-legal-person.json create mode 100644 examples/gaiax/gx-registration-number.json create mode 100644 examples/gaiax/gx-terms-and-conditions.json create mode 100644 examples/gaiax/legal-person-credential-embedded.json create mode 100644 examples/gaiax/participant-vp.json create mode 100644 examples/gaiax_external/Gaia-X_Loire Participant Credential VC_(2026)_signed.json create mode 100644 examples/gaiax_external/Gaia-X_Loire Participant Credential VC_(2026)_signed.jwt create mode 100644 examples/gaiax_external/Gaia-X_Loire Participant Credential VP_(2026).json create mode 100644 examples/gaiax_external/Gaia-X_Loire Participant Credential VP_(2026)_signed.jwt create mode 100644 linkml/harbour-core-delegation.yaml diff --git a/Makefile b/Makefile index 9bc6c29..21c7075 100644 --- a/Makefile +++ b/Makefile @@ -68,8 +68,11 @@ endef # LinkML schema files LINKML_SCHEMAS := $(wildcard linkml/*.yaml) -DOMAINS := harbour-core-credential harbour-gx-credential +DOMAINS := harbour-core-credential harbour-gx-credential harbour-core-delegation HARBOUR_EXAMPLE_FILES := $(wildcard examples/*.json) $(wildcard examples/gaiax/*.json) +HARBOUR_VALIDATE_PATH ?= +HARBOUR_VALIDATE_ALLOW_ONLINE ?= 1 +HARBOUR_VALIDATE_ENFORCE_REQUIRED_ONTOLOGIES ?= $(if $(strip $(HARBOUR_VALIDATE_PATH)),0,1) GROUPED_COMMANDS := install validate lint format test story build PRIMARY_GOAL := $(firstword $(MAKECMDGOALS)) @@ -128,6 +131,8 @@ _help_validate: @echo "Validate subcommands:" @echo " make validate - Run structural validation tests" @echo " make validate shacl - Run SHACL conformance on examples via OMB" + @echo " make validate shacl HARBOUR_VALIDATE_PATH=examples/... - Validate one Harbour .json/.jsonld file or folder" + @echo " make validate shacl HARBOUR_VALIDATE_ALLOW_ONLINE=0 - Disable OMB online fallback for did:web/http(s)" _help_lint: @echo "Lint subcommands:" @@ -289,27 +294,60 @@ _validate_shacl: @echo "Running SHACL data conformance check on examples..." @cd $(OMB_SUBMODULE_DIR) && \ tmp_output=$$(mktemp) && \ - $(PYTHON_ABS) -m src.tools.validators.validation_suite \ - --run check-data-conformance \ - --data-paths $(addprefix ../../,$(HARBOUR_EXAMPLE_FILES)) ../../examples/did-ethr/ ../../tests/validation-probe/ontology-loading-probe.json \ - --artifacts ../../artifacts > $$tmp_output 2>&1 ; \ + allow_online_flag="" ; \ + if [ "$(HARBOUR_VALIDATE_ALLOW_ONLINE)" = "0" ]; then \ + allow_online_flag="--offline" ; \ + fi ; \ + if [ -n "$(HARBOUR_VALIDATE_PATH)" ]; then \ + target_path="../../$(HARBOUR_VALIDATE_PATH)" ; \ + if [ -d "$$target_path" ]; then \ + json_count=$$(find "$$target_path" -maxdepth 1 -type f \( -name '*.json' -o -name '*.jsonld' \) | wc -l) ; \ + if [ "$$json_count" -eq 0 ]; then \ + echo "ERROR: No .json or .jsonld files found under $$target_path" >&2 ; \ + rm -f $$tmp_output ; \ + exit 1 ; \ + fi ; \ + elif [ -f "$$target_path" ]; then \ + case "$$target_path" in \ + *.json|*.jsonld) ;; \ + *) echo "ERROR: Harbour SHACL validation only supports .json/.jsonld files or directories: $$target_path" >&2 ; rm -f $$tmp_output ; exit 1 ;; \ + esac ; \ + else \ + echo "ERROR: Validation path not found: $$target_path" >&2 ; \ + rm -f $$tmp_output ; \ + exit 1 ; \ + fi ; \ + $(PYTHON_ABS) -m src.tools.validators.validation_suite \ + --run check-data-conformance \ + $$allow_online_flag \ + --data-paths "$$target_path" ../../examples/did-ethr/ ../../tests/validation-probe/ontology-loading-probe.json \ + --artifacts ../../artifacts > $$tmp_output 2>&1 ; \ + else \ + $(PYTHON_ABS) -m src.tools.validators.validation_suite \ + --run check-data-conformance \ + $$allow_online_flag \ + --data-paths $(addprefix ../../,$(HARBOUR_EXAMPLE_FILES)) ../../examples/did-ethr/ ../../tests/validation-probe/ontology-loading-probe.json \ + --artifacts ../../artifacts > $$tmp_output 2>&1 ; \ + fi ; \ status=$$? ; \ cat $$tmp_output ; \ if [ $$status -ne 0 ]; then \ rm -f $$tmp_output ; \ exit $$status ; \ fi ; \ - for required in \ - "imports/cs/cs.owl.ttl" \ - "imports/cred/cred.owl.ttl" \ - "../../artifacts/harbour-gx-credential/harbour-gx-credential.owl.ttl" \ - "artifacts/gx/gx.owl.ttl" ; do \ - if ! grep -q "$$required" $$tmp_output ; then \ - echo "ERROR: Required ontology not loaded by validation suite: $$required" >&2 ; \ - rm -f $$tmp_output ; \ - exit 1 ; \ - fi ; \ - done ; \ + if [ "$(HARBOUR_VALIDATE_ENFORCE_REQUIRED_ONTOLOGIES)" = "1" ]; then \ + for required in \ + "imports/cs/cs.owl.ttl" \ + "imports/cred/cred.owl.ttl" \ + "../../artifacts/harbour-gx-credential/harbour-gx-credential.owl.ttl" \ + "artifacts/gx/gx.owl.ttl" ; do \ + if ! grep -q "$$required" $$tmp_output ; then \ + echo "ERROR: Required ontology not loaded by validation suite: $$required" >&2 ; \ + rm -f $$tmp_output ; \ + exit 1 ; \ + fi ; \ + done ; \ + fi ; \ rm -f $$tmp_output @echo "OK: SHACL validation complete" diff --git a/README.md b/README.md index a7c337b..1d9e96f 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ Base skeleton examples live in `examples/` (no Gaia-X data). Gaia-X domain exten "@context": [ "https://www.w3.org/ns/credentials/v2", "https://w3id.org/gaia-x/development#", - "https://w3id.org/reachhaven/harbour/credentials/v1/" + "https://w3id.org/reachhaven/harbour/core/v1/" ], "type": ["VerifiableCredential", "harbour:LegalPersonCredential"], "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", diff --git a/docs/architecture.md b/docs/architecture.md index 3c96bc2..36052f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,7 +29,7 @@ flowchart TB subgraph infra["Infrastructure"] DID["DID Documents
(did:ethr, did:key)"] CRSET["CRSet Revocation"] - GX["Gaia-X Compliance
(gxParticipant)"] + GX["Gaia-X Compliance
(ComplianceCredential)"] end subgraph output["Outputs"] diff --git a/docs/guide/delegated-signing.md b/docs/guide/delegated-signing.md index 5ad7c68..07facdd 100644 --- a/docs/guide/delegated-signing.md +++ b/docs/guide/delegated-signing.md @@ -137,7 +137,7 @@ The signing service creates an OID4VP-aligned transaction data object (see [Dele ```json { - "type": "harbour_delegate:data.purchase", + "type": "harbour.delegate:data.purchase", "credential_ids": ["harbour_natural_person"], "transaction_data_hashes_alg": ["sha-256"], "nonce": "da9b1009", @@ -177,7 +177,7 @@ sd_jwt_vc = "eyJ...~disclosure1~disclosure2~..." evidence = [{ "type": "DelegatedSignatureEvidence", "transaction_data": { - "type": "harbour_delegate:data.purchase", + "type": "harbour.delegate:data.purchase", "credential_ids": ["harbour_natural_person"], "nonce": "da9b1009", "iat": 1771934400, @@ -211,7 +211,7 @@ const sdJwtVp = await issueSdJwtVp(sdJwtVc, holderPrivateKey, { evidence: [{ type: 'DelegatedSignatureEvidence', transaction_data: { - type: 'harbour_delegate:data.purchase', + type: 'harbour.delegate:data.purchase', credential_ids: ['harbour_natural_person'], nonce: 'da9b1009', iat: 1771934400, @@ -247,7 +247,7 @@ result = verify_sd_jwt_vp( # Check transaction data matches original request tx = result["evidence"][0]["transaction_data"] -assert tx["type"] == "harbour_delegate:data.purchase" +assert tx["type"] == "harbour.delegate:data.purchase" assert tx["txn"]["asset_id"] == "urn:uuid:550e8400-e29b-41d4-a716-446655440000" # Check credential is still valid (CRSet) @@ -359,7 +359,7 @@ User purchases dataset through blockchain: User signs legal contract: 1. Contract platform prepares document -2. Creates transaction data: `harbour_delegate:contract.sign` +2. Creates transaction data: `harbour.delegate:contract.sign` 3. User creates consent VP 4. Harbour records signature on blockchain 5. Receipt VP serves as proof of signing intent @@ -368,7 +368,7 @@ User signs legal contract: User grants access to resource: -1. Service creates transaction data: `harbour_delegate:data.access` +1. Service creates transaction data: `harbour.delegate:data.access` 2. User creates consent VP 3. Harbour updates access control on blockchain 4. Receipt VP serves as access grant evidence diff --git a/docs/guide/evidence.md b/docs/guide/evidence.md index b32ba57..0e9eaa4 100644 --- a/docs/guide/evidence.md +++ b/docs/guide/evidence.md @@ -19,26 +19,27 @@ Proves that an authorizing party approved the credential issuance via OID4VP. Th The Harbour Signing Service is the **sole issuer** of all credentials. Evidence VPs establish the chain of authorization: -**Use case 1 — Trust Anchor authorizes org (LegalPersonCredential)**: The Trust Anchor presents a VP containing its **self-signed LegalPersonCredential** (root of trust, analogous to a root CA certificate). The Signing Service verifies this VP and issues the org's credential with it as evidence. +**Use case 1 — Trust Anchor authorizes org (LegalPersonCredential)**: The Trust Anchor presents a VP containing its **self-signed LinkedCredentialService credential** (service endpoint proof, root of trust — analogous to a root CA certificate). The Signing Service verifies this VP and issues the org's credential with it as evidence. -**Use case 2 — Org authorizes employee (NaturalPersonCredential)**: The organization presents a VP containing its **LegalPersonCredential** (SD-JWT with sensitive fields redacted — registration number and addresses hidden, name/legalName disclosed). The Signing Service verifies this VP and issues the employee's credential with it as evidence. +**Use case 2 — Org authorizes employee (NaturalPersonCredential)**: The organization presents a VP containing its **LegalPersonCredential** (SD-JWT with sensitive fields redacted — registration number and addresses hidden, compliance status disclosed). The Signing Service verifies this VP and issues the employee's credential with it as evidence. ```json { "type": "harbour:CredentialEvidence", "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiablePresentation"], + "type": ["VerifiablePresentation", "harbour:VerifiablePresentation"], "holder": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "verifiableCredential": [ { - "@context": ["https://www.w3.org/ns/credentials/v2", "..."], - "type": ["VerifiableCredential", "harbour:LegalPersonCredential"], + "@context": ["https://www.w3.org/ns/credentials/v2", "https://w3id.org/reachhaven/harbour/core/v1/"], + "type": ["VerifiableCredential"], "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", - "type": "harbour:LegalPerson", - "name": "ReachHaven GmbH" + "type": "harbour:LinkedCredentialService", + "didcore:serviceEndpoint": {"id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3"} } } ] @@ -46,7 +47,7 @@ The Harbour Signing Service is the **sole issuer** of all credentials. Evidence } ``` -**What it proves**: The authorizing party (Trust Anchor or org) approved the Signing Service to issue a credential for the target subject. The chain of trust flows: Trust Anchor → org → employee. +**What it proves**: The authorizing party (Trust Anchor or org) approved the Signing Service to issue a credential for the target subject. The chain of trust flows: Trust Anchor (LinkedCredentialService) → org (LegalPersonCredential) → employee (NaturalPersonCredential). ### DelegatedSignatureEvidence @@ -60,7 +61,7 @@ Evidence on a **receipt credential** (SD-JWT-VC) that a signing service executed "verifiablePresentation": "", "delegatedTo": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "transaction_data": { - "type": "harbour_delegate:data.purchase", + "type": "harbour.delegate:data.purchase", "credential_ids": ["harbour_natural_person"], "transaction_data_hashes_alg": ["sha-256"], "nonce": "da9b1009", @@ -72,7 +73,7 @@ Evidence on a **receipt credential** (SD-JWT-VC) that a signing service executed "marketplace": "did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c" } }, - "challenge": "da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52" + "challenge": "da9b1009 HARBOUR_DELEGATE c3d4ba771c1103935ab4121874c4b3a78c8471719c80f60d59ca5811e232089b" } ``` diff --git a/docs/schema/credential-model.md b/docs/schema/credential-model.md index 29f5132..651a1ec 100644 --- a/docs/schema/credential-model.md +++ b/docs/schema/credential-model.md @@ -56,26 +56,32 @@ classDiagram issuer : uri ⟨required⟩ validFrom : datetime ⟨required⟩ validUntil : datetime - evidence : Evidence[] ⟨required⟩ + evidence : Evidence[] credentialStatus : CRSetEntry[] ⟨required⟩ } + class ComplianceCredential { + <> + evidence : Evidence[] ⟨required⟩ + } + class LegalPersonCredential { - class_uri = harbour:LegalPersonCredential + class_uri = harbour.gx:LegalPersonCredential vct = "…/LegalPersonCredential" validFrom : required evidence : required } class NaturalPersonCredential { - class_uri = harbour:NaturalPersonCredential + class_uri = harbour.gx:NaturalPersonCredential vct = "…/NaturalPersonCredential" validFrom : required evidence : required } W3C_VC_Envelope <|-- HarbourCredential : imports + strengthens - HarbourCredential <|-- LegalPersonCredential + HarbourCredential <|-- ComplianceCredential + ComplianceCredential <|-- LegalPersonCredential HarbourCredential <|-- NaturalPersonCredential ``` @@ -84,13 +90,20 @@ classDiagram The W3C VC Data Model v2.0 defines most envelope fields as optional. `HarbourCredential` tightens these for the harbour profile: -| Field | W3C VC v2.0 | HarbourCredential | -|-------|-------------|-------------------| -| `issuer` | optional | **required** | -| `validFrom` | optional | **required** | -| `validUntil` | optional | optional | -| `evidence` | optional | **required** | -| `credentialStatus` | optional | **required** (range: `CRSetEntry`) | +| Field | W3C VC v2.0 | HarbourCredential | ComplianceCredential / NPC | +|-------|-------------|-------------------|---------------------------| +| `issuer` | optional | **required** | **required** | +| `validFrom` | optional | **required** | **required** | +| `validUntil` | optional | optional | optional | +| `evidence` | optional | optional | **required** | +| `credentialStatus` | optional | **required** (range: `CRSetEntry`) | **required** | + +!!! note "Evidence requirement" + Evidence is optional at the base `HarbourCredential` level (e.g. the + Trust Anchor's self-signed `LinkedCredentialService` credential has no + evidence — it is the root of trust). Domain-specific types + (`ComplianceCredential`, `NaturalPersonCredential`) make evidence + **required** via `slot_usage` overrides. !!! note "Downstream overrides" Consumers like SimpulseID may loosen these constraints via `slot_usage`. @@ -109,27 +122,27 @@ classDiagram class Evidence { <> type : string ⟨required⟩ - verifier : uri ⟨required⟩ - verificationMethod : uri ⟨required⟩ } class CredentialEvidence { - evidenceDocument : uri - subjectPresence : string - documentPresence : string + verifiablePresentation : VP ⟨required⟩ } class DelegatedSignatureEvidence { - challenge : string ⟨required⟩ - domain : string ⟨required⟩ + verifiablePresentation : VP ⟨required⟩ + delegatedTo : uri ⟨required⟩ + transaction_data : object ⟨required⟩ + challenge : string } Evidence <|-- CredentialEvidence Evidence <|-- DelegatedSignatureEvidence ``` -**`CredentialEvidence`** — attests that a human verifier checked documents -(identity papers, registration certificates) before issuance. +**`CredentialEvidence`** — attests that an authorizing party approved the +credential issuance via OID4VP. The embedded VP contains the authorizer's +credential (Trust Anchor's LinkedCredentialService for org issuance, or +org's LegalPersonCredential for employee issuance). **`DelegatedSignatureEvidence`** — attests that the subject authorized a signing service to act on their behalf via an OID4VP challenge-response @@ -143,95 +156,120 @@ Subject types define what a credential asserts about a person or organisation. These are **not** inherited from `HarbourCredential` — they are standalone classes used as the `credentialSubject` value. +### harbour.gx:LegalPerson — Compliance Attestation + +`harbour.gx:LegalPerson` is a **pure compliance type** — it does NOT contain +entity data (name, addresses, registrationNumber). Entity data lives in the +referenced plain `gx:LegalPerson` input VC. This type only carries compliance +enforcement slots with SHACL `sh:minCount 1`: + ```mermaid classDiagram - class LegalPerson { - class_uri = harbour:LegalPerson - name : string - gxParticipant : Any + class HarbourLegalPerson { + class_uri = harbour.gx:LegalPerson + compliantLegalPersonVC : CompliantCredentialReference ⟨required⟩ + compliantRegistrationVC : CompliantCredentialReference ⟨required⟩ + compliantTermsVC : CompliantCredentialReference ⟨required⟩ + labelLevel : string ⟨required⟩ + engineVersion : string ⟨required⟩ + rulesVersion : string ⟨required⟩ + validatedCriteria : string[] ⟨required⟩ + } + + class CompliantCredentialReference { + class_uri = harbour.gx:CompliantCredentialReference + credentialType : string ⟨required⟩ + digestSRI : string ⟨required⟩ + embeddedCredential : string } - class NaturalPerson { - class_uri = harbour:NaturalPerson - name : string - gxParticipant : Any + class HarbourNaturalPerson { + class_uri = harbour.gx:NaturalPerson givenName : string familyName : string email : string memberOf : uri + address : gx:Address } + + HarbourLegalPerson --> CompliantCredentialReference : 3 required refs ``` ### Credential ↔ Subject Pairing | Credential Type | Subject Type | Use Case | |----------------|-------------|----------| -| `LegalPersonCredential` | `LegalPerson` | Organisation identity | -| `NaturalPersonCredential` | `NaturalPerson` | Individual identity | +| `harbour.gx:LegalPersonCredential` | `harbour.gx:LegalPerson` | Organisation compliance attestation | +| `harbour.gx:NaturalPersonCredential` | `harbour.gx:NaturalPerson` | Individual identity | --- -## Gaia-X Composition Pattern +## Gaia-X Compliance Model + +Gaia-X requires three mandatory VCs for participant compliance +([GX Architecture Document 25.11](https://docs.gaia-x.eu/technical-committee/architecture-document/25.11/)): + +1. **gx:LegalPerson** — self-signed entity identity (name, addresses, registration) +2. **gx:VatID** — notary-verified registration number +3. **gx:Issuer** — self-signed Terms & Conditions acceptance + +Harbour's `LegalPersonCredential` IS the compliance credential — holding +a valid one means Haven has verified all three underlying Gaia-X VCs. + +### How It Works -Gaia-X Trust Framework defines **closed SHACL shapes** (`sh:closed true`) -on `gx:LegalPerson` and `gx:Participant`. Adding any non-gx property to -a `gx:` node violates the closed shape constraint. +The input VCs are **plain Gaia-X** (type: `VerifiableCredential` only, no +harbour envelope). Haven verifies them and issues a `LegalPersonCredential` +whose `credentialSubject` (type: `harbour.gx:LegalPerson`) contains: -Harbour solves this with **composition** — the outer harbour node owns -harbour-specific properties, and a nested blank node carries only -gx-valid properties: +- Three `CompliantCredentialReference` slots with `digestSRI` integrity hashes +- Compliance metadata (`labelLevel`, `engineVersion`, `rulesVersion`, `validatedCriteria`) ```mermaid graph TD - subgraph "harbour:LegalPerson (outer node)" - A["harbour:name = 'ACME Corp'"] - B["harbour:gxParticipant"] + subgraph "Input: Plain Gaia-X VCs" + A["gx:LegalPerson VC
(self-signed by org)"] + B["gx:VatID VC
(notary-signed by Haven)"] + C["gx:Issuer VC
(self-signed T&C)"] end - subgraph "_:b0 (gx blank node)" - C["@type = gx:LegalPerson"] - D["gx:registrationNumber = …"] - E["gx:legalAddress = …"] - F["gx:headquartersAddress = …"] + subgraph "Output: Harbour Compliance Credential" + D["harbour.gx:LegalPersonCredential
(issued by Haven)"] + E["credentialSubject:
harbour.gx:LegalPerson"] + F["compliantLegalPersonVC
+ digestSRI"] + G["compliantRegistrationVC
+ digestSRI"] + H["compliantTermsVC
+ digestSRI"] end - B --> C + A -->|verified| D + B -->|verified| D + C -->|verified| D + D --> E + E --> F & G & H - style A fill:#f3e5f5,stroke:#6a1b9a - style B fill:#f3e5f5,stroke:#6a1b9a + style A fill:#e8f5e9,stroke:#2e7d32 + style B fill:#e8f5e9,stroke:#2e7d32 style C fill:#e8f5e9,stroke:#2e7d32 - style D fill:#e8f5e9,stroke:#2e7d32 - style E fill:#e8f5e9,stroke:#2e7d32 - style F fill:#e8f5e9,stroke:#2e7d32 + style D fill:#f3e5f5,stroke:#6a1b9a + style E fill:#f3e5f5,stroke:#6a1b9a + style F fill:#fff3e0,stroke:#e65100 + style G fill:#fff3e0,stroke:#e65100 + style H fill:#fff3e0,stroke:#e65100 ``` -### Why Not Extend gx:LegalPerson Directly? +### Two Delivery Patterns -Adding harbour properties to a `gx:` node violates `sh:closed`: +**Referenced pattern** — input VCs are referenced by `digestSRI` hash only. +The full VCs are delivered separately (e.g. in a Verifiable Presentation +or via a credential registry). -```turtle -# ❌ Wrong — SHACL violation -harbour:MyOrg a gx:LegalPerson ; - gx:registrationNumber … ; - harbour:extraField "value" . -``` - -Composition keeps gx shapes intact: - -```turtle -# ✅ Correct — separate nodes -harbour:MyOrg a harbour:LegalPerson ; - harbour:name "ACME" ; - harbour:gxParticipant [ - a gx:LegalPerson ; - gx:registrationNumber … - ] . -``` +**Embedded pattern** — input VCs are JSON-stringified inside +`embeddedCredential` for self-contained verification. The `digestSRI` +still serves as integrity proof. -The `gxParticipant` slot has `range: Any` because the nested content is -validated by Gaia-X's own SHACL shapes (`gx.shacl.ttl`), not harbour's. -Harbour generates its SHACL with `exclude_imports=True` to keep shape -sets separate. +Harbour generates its SHACL with `exclude_imports=True` to avoid +duplicating gx shapes. Gaia-X shapes are validated separately via the +ontology-management-base pipeline. --- @@ -351,7 +389,9 @@ For quick reference, every class defined across all three schema files: | `TrustAnchorService` | core | — | *(Service union)* | Core | | `LinkedCredentialService` | core | — | *(Service union)* | Core | | `CRSetRevocationRegistryService` | core | — | *(Service union)* | Core | -| `LegalPersonCredential` | gx | — | `HarbourCredential` | Gaia-X | +| `ComplianceCredential` | gx | ✅ | `HarbourCredential` | Gaia-X | +| `LegalPersonCredential` | gx | — | `ComplianceCredential` | Gaia-X | | `NaturalPersonCredential` | gx | — | `HarbourCredential` | Gaia-X | -| `LegalPerson` | gx | — | — | Gaia-X | -| `NaturalPerson` | gx | — | — | Gaia-X | +| `HarbourLegalPerson` | gx | — | — | Gaia-X | +| `CompliantCredentialReference` | gx | — | — | Gaia-X | +| `HarbourNaturalPerson` | gx | — | `gx:Participant` | Gaia-X | diff --git a/docs/specs/delegation-challenge-encoding.md b/docs/specs/delegation-challenge-encoding.md index 01698d3..f57a275 100644 --- a/docs/specs/delegation-challenge-encoding.md +++ b/docs/specs/delegation-challenge-encoding.md @@ -49,7 +49,7 @@ Where: ### 2.2 Example ```text -da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52 +da9b1009 HARBOUR_DELEGATE c3d4ba771c1103935ab4121874c4b3a78c8471719c80f60d59ca5811e232089b ``` This format uses a compact, single-line structure designed for QR code presentation while maintaining full auditability via the hash binding. @@ -89,7 +89,7 @@ This structure aligns with [OID4VP §5.1 `transaction_data`](https://openid.net/ ```json { - "type": "harbour_delegate:", + "type": "harbour.delegate:", "credential_ids": [""], "transaction_data_hashes_alg": ["sha-256"], "nonce": "", @@ -105,7 +105,7 @@ This structure aligns with [OID4VP §5.1 `transaction_data`](https://openid.net/ | Field | Type | OID4VP | Description | |-------|------|--------|-------------| -| `type` | string | REQUIRED | Transaction data type identifier. Format: `harbour_delegate:` | +| `type` | string | REQUIRED | Transaction data type identifier. Format: `harbour.delegate:` | | `credential_ids` | string[] | REQUIRED | References to DCQL Credential Query `id` fields that can authorize this transaction | | `nonce` | string | Extension | Unique identifier for replay protection (same as in challenge) | | `iat` | number | Extension | Issued-at Unix timestamp (seconds since epoch) | @@ -123,11 +123,11 @@ This structure aligns with [OID4VP §5.1 `transaction_data`](https://openid.net/ | Action Type | `txn` Fields | |-------------|--------------| -| `harbour_delegate:blockchain.transfer` | `chain`, `contract`, `recipient`, `amount`, `token` | -| `harbour_delegate:blockchain.execute` | `chain`, `contract`, `method`, `params`, `value` | -| `harbour_delegate:data.purchase` | `asset_id`, `price`, `currency`, `marketplace` | -| `harbour_delegate:contract.sign` | `document_hash`, `document_uri`, `parties` | -| `harbour_delegate:credential.issue` | `credential_type`, `subject`, `claims` | +| `harbour.delegate:blockchain.transfer` | `chain`, `contract`, `recipient`, `amount`, `token` | +| `harbour.delegate:blockchain.execute` | `chain`, `contract`, `method`, `params`, `value` | +| `harbour.delegate:data.purchase` | `asset_id`, `price`, `currency`, `marketplace` | +| `harbour.delegate:contract.sign` | `document_hash`, `document_uri`, `parties` | +| `harbour.delegate:credential.issue` | `credential_type`, `subject`, `claims` | #### Naming Conventions and Compatibility Boundary @@ -145,7 +145,7 @@ Important: `txn` keys are part of canonicalization and hashing. Renaming a key ( ```json { - "type": "harbour_delegate:data.purchase", + "type": "harbour.delegate:data.purchase", "credential_ids": ["harbour_natural_person"], "transaction_data_hashes_alg": ["sha-256"], "nonce": "da9b1009", @@ -179,7 +179,7 @@ def compute_transaction_hash(transaction_data: dict) -> str: The resulting challenge: ```text -da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52 +da9b1009 HARBOUR_DELEGATE c3d4ba771c1103935ab4121874c4b3a78c8471719c80f60d59ca5811e232089b ``` --- @@ -212,7 +212,7 @@ The delegated consent is captured as `evidence` in a Verifiable Credential or di "type": "DataIntegrityProof", "cryptosuite": "ecdsa-rdfc-2019", "proofPurpose": "authentication", - "challenge": "da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52", + "challenge": "da9b1009 HARBOUR_DELEGATE c3d4ba771c1103935ab4121874c4b3a78c8471719c80f60d59ca5811e232089b", "domain": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "verificationMethod": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#controller", "created": "2026-02-24T12:00:05Z", @@ -280,7 +280,7 @@ This specification is designed for seamless integration with [OpenID for Verifia ```json { - "type": "harbour_delegate:data.purchase", + "type": "harbour.delegate:data.purchase", "credential_ids": ["harbour_natural_person"], "transaction_data_hashes_alg": ["sha-256"], "nonce": "da9b1009", @@ -503,7 +503,7 @@ These examples use the shared test vectors from `tests/fixtures/canonicalization ```json { - "type": "harbour_delegate:data.purchase", + "type": "harbour.delegate:data.purchase", "credential_ids": ["harbour_natural_person"], "transaction_data_hashes_alg": ["sha-256"], "nonce": "da9b1009", @@ -520,7 +520,7 @@ These examples use the shared test vectors from `tests/fixtures/canonicalization **Challenge:** ```text -da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52 +da9b1009 HARBOUR_DELEGATE c3d4ba771c1103935ab4121874c4b3a78c8471719c80f60d59ca5811e232089b ``` ### 10.2 Blockchain Transfer Transaction @@ -529,7 +529,7 @@ da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba ```json { - "type": "harbour_delegate:blockchain.transfer", + "type": "harbour.delegate:blockchain.transfer", "credential_ids": ["default"], "transaction_data_hashes_alg": ["sha-256"], "nonce": "ef567890", @@ -546,7 +546,7 @@ da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba **Challenge:** ```text -ef567890 HARBOUR_DELEGATE 0736db89c15be412294f96717a3e435f89d095e7e953b1808c422252b845d4c1 +ef567890 HARBOUR_DELEGATE 66d8768b6f6ae9d952f61c85414d22d504341da5d0ff0f65a45398246f1f630a ``` ### 10.3 Contract Signature Transaction @@ -555,7 +555,7 @@ ef567890 HARBOUR_DELEGATE 0736db89c15be412294f96717a3e435f89d095e7e953b1808c4222 ```json { - "type": "harbour_delegate:contract.sign", + "type": "harbour.delegate:contract.sign", "credential_ids": ["org_credential"], "transaction_data_hashes_alg": ["sha-256"], "nonce": "ab12cd34", @@ -572,7 +572,7 @@ ef567890 HARBOUR_DELEGATE 0736db89c15be412294f96717a3e435f89d095e7e953b1808c4222 **Challenge:** ```text -ab12cd34 HARBOUR_DELEGATE 0863ac13bc5f15c7dfcdee71b8beea1aead4b822d0a7c03154405da4f192af08 +ab12cd34 HARBOUR_DELEGATE 573cc3da4d63242b2d8b950b29507b9b1e414d9330d3bb245ce7fb264b259601 ``` --- @@ -603,7 +603,7 @@ This specification aligns with [OID4VP Transaction Data (§8.4)](https://openid. | OID4VP Concept | Harbour Delegation Equivalent | |----------------|-------------------------------| | `transaction_data` request param | Transaction Data Object (§3) | -| `transaction_data.type` | `"harbour_delegate:"` | +| `transaction_data.type` | `"harbour.delegate:"` | | `transaction_data.txn` | Action-specific transaction details | | `transaction_data_hashes` in KB-JWT | OID4VP hash over transaction_data request string | | `transaction_data_hashes_alg` | `"sha-256"` | @@ -618,7 +618,7 @@ OID4VP authorization request: "client_id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "nonce": "da9b1009", "transaction_data": [{ - "type": "harbour_delegate:data.purchase", + "type": "harbour.delegate:data.purchase", "credential_ids": ["harbour_natural_person"], "transaction_data_hashes_alg": ["sha-256"], "nonce": "da9b1009", diff --git a/docs/specs/references/README.md b/docs/specs/references/README.md index 5bdd7e0..e319bf0 100644 --- a/docs/specs/references/README.md +++ b/docs/specs/references/README.md @@ -20,6 +20,7 @@ They are copies of specifications published by their respective standards organi | `oid4vp-1.0.md` | [OpenID4VP 1.0](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html) | OpenID Foundation | [OpenID IPR](https://openid.net/intellectual-property/) | | `oid4vp-1.0.txt` | Raw full spec text (3,834 lines) — retained for search | OpenID Foundation | [OpenID IPR](https://openid.net/intellectual-property/) | | `gx-architecture-document-25.11.md` | [Gaia-X AD 25.11](https://docs.gaia-x.eu/technical-committee/architecture-document/25.11/) | Gaia-X AISBL | CC BY-NC-ND 4.0 | +| `gx-compliance-document-25.10.md` | [Gaia-X CD 25.10 (Loire)](https://docs.gaia-x.eu/policy-rules-committee/compliance-document/25.10/) | Gaia-X AISBL | CC BY-NC-ND 4.0 | | `csc-data-model.md` | [CSC Data Model v1.0.0](https://cloudsignatureconsortium.org/wp-content/uploads/2025/10/data-model-bindings.pdf) | Cloud Signature Consortium | CSC License | | `did-ethr-method-spec.md` | [did:ethr Method Specification](https://github.com/decentralized-identity/ethr-did-resolver/blob/master/doc/did-method-spec.md) | DIF | Apache-2.0 | | `did-webs-spec.md` | [did:webs Specification](https://github.com/trustoverip/tswg-did-method-webs-specification) | Trust Over IP Foundation | [ToIP License](https://github.com/trustoverip/tswg-did-method-webs-specification/blob/main/LICENSE.md) | diff --git a/docs/specs/references/gx-architecture-document-25.11.md b/docs/specs/references/gx-architecture-document-25.11.md index eb46a11..d5f0520 100644 --- a/docs/specs/references/gx-architecture-document-25.11.md +++ b/docs/specs/references/gx-architecture-document-25.11.md @@ -53,31 +53,33 @@ are permitted on `gx:LegalPerson` nodes: | `schema:name` | `xsd:string` | OPTIONAL (≤1) | Human-readable name | | `schema:description` | `xsd:string` | OPTIONAL (≤1) | Description | -### Closed Shape Constraint — Why Composition +### Compliance Model — Why Not Extend gx:LegalPerson Directly? Because `gx:LegalPersonShape` has `sh:closed true`: - Adding ANY property not in the shape to a `gx:LegalPerson` node will **fail** SHACL validation. - Harbour cannot extend `gx:LegalPerson` with additional properties. -- Therefore Harbour uses **composition** (not extension): the harbour outer - node carries harbour-specific properties, and a nested gx blank node - carries only gx-valid properties. +- Therefore Harbour uses a **separate compliance attestation type**: + `harbour.gx:LegalPerson` carries only compliance enforcement slots + (VC references + metadata). Entity data lives in the referenced + plain `gx:LegalPerson` input VC. -### Composition Pattern +### Compliance Pattern ``` -harbour:LegalPerson # harbour outer node - ├── schema:name "ACME Corp" # harbour property - └── harbour:gxParticipant # composition link - └── gx:LegalPerson # gx blank node (closed shape) - ├── gx:registrationNumber ... - ├── gx:headquartersAddress ... - └── gx:legalAddress ... +harbour.gx:LegalPerson # compliance attestation node + ├── harbour.gx:compliantLegalPersonVC # → gx:LegalPerson VC ref + digestSRI + ├── harbour.gx:compliantRegistrationVC # → gx:VatID VC ref + digestSRI + ├── harbour.gx:compliantTermsVC # → gx:Issuer VC ref + digestSRI + ├── harbour.gx:labelLevel "SC" + ├── harbour.gx:engineVersion "2.11.0" + ├── harbour.gx:rulesVersion "CD25.10" + └── harbour.gx:validatedCriteria [...] ``` -This pattern keeps gx closed shapes intact while allowing harbour to -carry its own properties on the outer node. +This pattern keeps gx closed shapes intact — entity data stays on the +gx nodes, compliance metadata stays on the harbour node. ### Trust Framework Compliance @@ -100,9 +102,13 @@ carry its own properties on the outer node. ## Harbour Usage - `harbour-gx-credential.yaml` defines `LegalPersonCredential` and - `NaturalPersonCredential` with `gxParticipant` composition slot. -- The `gxParticipant` slot has `range: Any` because the gx blank node - content is validated by gx's own SHACL shapes, not harbour's. + `NaturalPersonCredential` with compliance enforcement slots. +- `LegalPersonCredential` IS the compliance credential — holding a valid + one means Haven verified the three underlying Gaia-X VCs (LegalPerson, + VatID, Issuer/T&C). See [GX-CD 25.10](gx-compliance-document-25.10.md). +- `harbour.gx:LegalPerson` is a pure compliance attestation type with + SHACL-enforced `CompliantCredentialReference` slots. +- `harbour.gx:NaturalPerson` extends `gx:Participant` directly. - Domain SHACL is generated with `exclude_imports=True` to keep harbour shapes separate from gx shapes. - Version tracking via `artifacts/gx/VERSION` and `verify-version.sh`. diff --git a/docs/specs/references/gx-compliance-document-25.10.md b/docs/specs/references/gx-compliance-document-25.10.md new file mode 100644 index 0000000..4e66449 --- /dev/null +++ b/docs/specs/references/gx-compliance-document-25.10.md @@ -0,0 +1,229 @@ +# Gaia-X Compliance Document 25.10 (Loire) + +**Status:** Published (2024) +**Publisher:** Gaia-X European Association for Data and Cloud AISBL +**Release:** Loire (CD25.10) +**URL:** https://docs.gaia-x.eu/policy-rules-committee/compliance-document/25.10/ +**PDF:** https://docs.gaia-x.eu/policy-rules-committee/compliance-document/25.10/pdf/document.pdf +**License:** CC BY-NC-ND 4.0 + +## Key Sections + +| Section | Title | URL | +|---------|-------|-----| +| §3 | Introduction & Scope | https://docs.gaia-x.eu/policy-rules-committee/compliance-document/25.10/Introduction_and_scope/ | +| §5 | Compliance Criteria for Participants | https://docs.gaia-x.eu/policy-rules-committee/compliance-document/25.10/criteria_participant/ | +| §8 | Gaia-X Trust Anchors | https://docs.gaia-x.eu/policy-rules-committee/compliance-document/25.10/Gaia-X_Trust_Anchors/ | +| §10 | Label Format | https://docs.gaia-x.eu/policy-rules-committee/compliance-document/latest/annex_label_format/ | +| §12 | Process for Becoming Gaia-X Compliant | https://docs.gaia-x.eu/policy-rules-committee/compliance-document/25.10/Process/ | + +## Related Ontology Pages + +| Type | URL | +|------|-----| +| gx:Participant (abstract) | https://docs.gaia-x.eu/ontology/development/classes/Participant/ | +| gx:LegalPerson | https://docs.gaia-x.eu/ontology/development/classes/LegalPerson/ | +| gx:Issuer (T&C) | https://docs.gaia-x.eu/ontology/development/classes/Issuer/ | + +--- + +## §3 Introduction & Scope + +### §3.1 Design Principles — Label Levels + +Gaia-X defines four conformity assessment schemes: + +| Property | Standard Compliance (SC) | Level 1 (L1) | Level 2 (L2) | Level 3 (L3) | +|----------|:---:|:---:|:---:|:---:| +| Declaration of Service or Product | ✔️ | ✔️ | ✔️ | ✔️ | +| Signed with verified method (e.g. eIDAS) | ✔️ | ✔️ | ✔️ | ✔️ | +| Automated validation by GXDCH | ✔️ | ✔️ | ✔️ | ✔️ | +| Automated verification by GXDCH | ✔️ | ✔️ | ➕ | ➕ | +| Data Exchange Policies | ✔️ | ✔️ | ✔️ | ✔️ | +| Certified Label Logo | | ✔️ | ✔️ | ✔️ | +| Data protection by EU legislation | | | ✔️ | ✔️ | +| Manual verification by CAB | | | ✔️ | ✔️ | +| Provider Headquarter within EU | | | | ✔️ | + +### §3.3 Extendibility + +Gaia-X Compliance applies to **all** Gaia-X Service Offerings. There shall +be a Gaia-X Credential for **all** entities defined in the Gaia-X Conceptual +model: Participant (incl. Consumer/Provider), Service Offering, Resource. + +The Gaia-X Compliance scheme can be extended by an ecosystem as detailed in +the Architecture Document. + +### §3.4 Period of Validity + +The targeted updating period is 18 months. Participants may remain qualified +under former requirements for max 12 months after a revision. + +--- + +## §5 Compliance Criteria for Participants + +A Gaia-X Participant is a legal or natural person that has a Gaia-X +Participant Credential. A Gaia-X Participant can take several roles: +consumer, producer, federator, operator, intermediary. + +### §5.1 Criteria + +**Criterion PA1.1**: The participant issuing its own Gaia-X Participant +Credential shall provide the information according to the Gaia-X Participant +ontology (https://docs.gaia-x.eu/ontology/development/classes/Participant/) +and shall agree and sign the Gaia-X Terms & Conditions as described in the +Gaia-X Ontology for Issuers +(https://docs.gaia-x.eu/ontology/development/classes/Issuer/). + +In case the participant is a legal person, the participant (or its power of +attorney) shall provide information according to the Gaia-X Legal Person +ontology (https://docs.gaia-x.eu/ontology/development/classes/LegalPerson/). + +**Required for:** Standard Compliance (SC) — declaration. N/A for L1-L3. + +### Gaia-X Terms & Conditions + +> The Gaia-X credentials issuer agrees to update its Gaia-X credentials +> about any changes, be it technical, organisational, or legal — especially +> but not limited to contractual in regards to the indicated attributes +> present in the Gaia-X credentials. +> +> The certificate or public key of the keypair used to sign Gaia-X +> Credentials will be marked as untrusted where the Gaia-X European +> Association for Data and Cloud becomes aware of any inaccurate statements +> regarding the claims which results in non-compliance with the Compliance +> Document. + +### Three Required Gaia-X VCs for Participant Compliance + +Per PA1.1, a compliant participant must present these three VCs: + +1. **gx:LegalPerson** — Self-signed entity identity credential + - Contains: registrationNumber (≥1), legalAddress (=1), headquartersAddress (=1) + - Ontology: https://docs.gaia-x.eu/ontology/development/classes/LegalPerson/ + - SHACL: sh:closed true (no additional properties allowed on gx:LegalPerson nodes) + +2. **gx:VatID** (or other RegistrationNumber) — Notary-signed registration number + - Signed by an accredited Gaia-X Notary after verification against Trusted Data Sources + - Trusted Data Sources: EORI (EC API), leiCode (GLEIF API), local (OpenCorporate), vatID (VIES) + - See §8.3 + +3. **gx:Issuer** — Self-signed Terms & Conditions acceptance + - Contains: gx:gaiaxTermsAndConditions (SHA-256 hash of T&C text) + - Ontology: https://docs.gaia-x.eu/ontology/development/classes/Issuer/ + +These three VCs are bundled into a Verifiable Presentation and submitted to +the Gaia-X Compliance Service (GXDCH). On success, a compliance credential +(Gaia-X Label) is issued. + +--- + +## §8 Gaia-X Trust Anchors + +Trust Anchors are bodies/parties accredited by Gaia-X to issue attestations +about specific claims. They are NOT necessarily Root CAs — they can be +relative to different properties in a claim. + +### §8.2 Trust Anchor Types + +**§8.2.1 Signee's Role** — For specific dependent attributes, a criterion +can mandate that an attribute must be signed by the same issuer (signee) of +another attribute. + +**§8.2.2 Trust Service Provider (TSP)** — All claims must be signed with +cryptographic material traceable to a Trust Anchor (usually a TSP). Accepted +TSP categories: + +- EEA: eIDAS Regulation (EU) No 910/2014 +- India: CCA +- South Korea: KTNET +- UAE: PASS +- Global fallback: Extended Validation (EV) SSL certificates + +### §8.3 Trusted Data Sources and Notaries + +When a Trust Anchor cannot issue cryptographic material directly, Gaia-X +accredits Notaries to convert "not machine readable" proofs into "machine +readable" proofs. A Gaia-X Notary must be a Gaia-X participant. + +Accredited Trusted Data Sources for registration numbers: +- **EORI**: EC API (https://ec.europa.eu/taxation_customs/dds2/eos/validation/services/validation?wsdl) +- **leiCode**: GLEIF API (https://www.gleif.org/en/lei-data/gleif-api) +- **local**: OpenCorporate API (https://api.opencorporates.com/) +- **vatID**: VIES API (https://ec.europa.eu/taxation_customs/vies/checkVatTestService.wsdl) + +--- + +## §10 Label Format + +A Gaia-X Label is a machine readable, structured and signed document (VC) +containing at minimum: + +- Label ID (unique identifier) +- Participant ID (unique identifier) +- Participant Business ID (firm business ID) +- Service Offering (for which the Label applies) +- Conformity assessment scheme (SC, L1, L2, or L3) +- Reference to the assessment scheme version (e.g. CD25.10) +- Compliance Service ID (GXDCH instance) +- Compliance Service version (software version) +- Issuance date +- Validity start and end date + +--- + +## §12 Process for Becoming a Gaia-X Compliant User + +Prerequisites: +1. Familiar with Gaia-X concepts (VCs, digital signatures, certificates, wallets) +2. Has an EV SSL or eIDAS certificate; public part published via DID:WEB +3. Familiar with Architecture Document workflow + +Steps: +- **A**: User wants Gaia-X Compliant VCs +- **B**: User chooses VC type (e.g. LegalParticipant) from Gaia-X Registry +- **C**: User chooses method: Wizard (https://wizard.lab.gaia-x.eu/) or + direct API (https://compliance.gaia-x.eu/) +- **D**: User creates credential payload with mandatory + optional attributes +- **E**: User signs credentials with their private key +- **F**: User creates a Verifiable Presentation including all required VCs +- **G**: User calls Gaia-X Compliance Service (connected to GXDCH instances) +- **H1**: If verification fails → error message with issue details +- **H2**: If verification succeeds → user receives Gaia-X Verifiable Credential + +The Gaia-X VC contains proof of verification, signed by the Clearing House. +After receiving it, the participant can claim Gaia-X Conformant status. + +Storage options: +1. JSON file on user's device +2. Digital wallet +3. Pushed to Credential Event Service (basis for Federated Catalogues) + +--- + +## Harbour Mapping + +Harbour maps the Gaia-X compliance flow as follows: + +| Gaia-X Concept | Harbour Implementation | +|----------------|----------------------| +| Compliance Service (GXDCH) | Haven (compliance service) | +| gx:LegalPerson VC | `examples/gaiax/gx-legal-person.json` | +| gx:VatID VC (notary) | `examples/gaiax/gx-registration-number.json` | +| gx:Issuer VC (T&C) | `examples/gaiax/gx-terms-and-conditions.json` | +| Compliance Credential (Label) | `harbour.gx:LegalPersonCredential` | +| Label Level | `harbour.gx:labelLevel` (SC, L1, L2, L3) | +| Assessment version | `harbour.gx:rulesVersion` (e.g. "CD25.10") | +| Compliance engine version | `harbour.gx:engineVersion` | +| Validated criteria | `harbour.gx:validatedCriteria` (URI list) | +| digestSRI on CompliantCredentialReference | Integrity hash per [SRI] spec | + +### Key Design Decision + +`harbour.gx:LegalPersonCredential` IS the compliance credential — holding +a valid one means Haven has verified all three required Gaia-X VCs. The +input VCs are plain Gaia-X (type: VerifiableCredential only, no harbour +envelope). SHACL shapes enforce the presence of all three VC references +with `sh:minCount 1` — machine-readable enforcement that the Gaia-X Loire +specification process leaves implicit. diff --git a/examples/README.md b/examples/README.md index b970a54..1537c32 100644 --- a/examples/README.md +++ b/examples/README.md @@ -39,8 +39,8 @@ The examples use a **two-tier layout**: (registration number, addresses) and reference the `https://w3id.org/gaia-x/development#` context. -Credential types in the Gaia-X layer use the `harbour_gx:` namespace prefix -(e.g. `harbour_gx:LegalPersonCredential`, `harbour_gx:NaturalPerson`) while core +Credential types in the Gaia-X layer use the `harbour.gx:` namespace prefix +(e.g. `harbour.gx:LegalPersonCredential`, `harbour.gx:NaturalPerson`) while core types use `harbour:` (e.g. `harbour:CRSetEntry`, `harbour:CredentialEvidence`). ## Credential Issuance Model @@ -200,7 +200,7 @@ organizational affiliation without the credential itself leaking PII. ```python # Python — convert to SD-JWT-VC flat claims from credentials.claim_mapping import vc_to_sd_jwt_claims, MAPPINGS -mapping = MAPPINGS["harbour_gx:NaturalPersonCredential"] +mapping = MAPPINGS["harbour.gx:NaturalPersonCredential"] claims, disclosable = vc_to_sd_jwt_claims(credential, mapping) # claims: {"iss": ..., "vct": ..., "givenName": "Alice", "memberOf": "did:ethr:0x14a34:0x..."} # disclosable: ["givenName", "familyName", "email", "memberOf"] diff --git a/examples/credential-with-evidence.json b/examples/credential-with-evidence.json index c206dec..1a72661 100644 --- a/examples/credential-with-evidence.json +++ b/examples/credential-with-evidence.json @@ -1,7 +1,7 @@ { "@context": [ "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/reachhaven/harbour/credentials/v1/" + "https://w3id.org/reachhaven/harbour/core/v1/" ], "type": [ "VerifiableCredential", @@ -40,7 +40,7 @@ { "@context": [ "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/reachhaven/harbour/credentials/v1/" + "https://w3id.org/reachhaven/harbour/core/v1/" ], "type": [ "VerifiableCredential" @@ -48,7 +48,11 @@ "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3" + "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "type": "harbour:LinkedCredentialService", + "didcore:serviceEndpoint": { + "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3" + } } } ] diff --git a/examples/credential-with-nested-evidence.json b/examples/credential-with-nested-evidence.json index 23742d5..c4ff026 100644 --- a/examples/credential-with-nested-evidence.json +++ b/examples/credential-with-nested-evidence.json @@ -1,7 +1,7 @@ { "@context": [ "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/reachhaven/harbour/credentials/v1/" + "https://w3id.org/reachhaven/harbour/core/v1/" ], "type": [ "VerifiableCredential", @@ -40,7 +40,7 @@ { "@context": [ "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/reachhaven/harbour/credentials/v1/" + "https://w3id.org/reachhaven/harbour/core/v1/" ], "type": [ "VerifiableCredential" @@ -48,7 +48,11 @@ "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" + "id": "did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + "type": "harbour:LinkedCredentialService", + "didcore:serviceEndpoint": { + "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" + } } } ] diff --git a/examples/did-ethr/harbour-trust-anchor.did.json b/examples/did-ethr/harbour-trust-anchor.did.json index ccee23c..1e4e35c 100644 --- a/examples/did-ethr/harbour-trust-anchor.did.json +++ b/examples/did-ethr/harbour-trust-anchor.did.json @@ -1,7 +1,7 @@ { "@context": [ "https://www.w3.org/ns/did/v1", - "https://w3id.org/reachhaven/harbour/credentials/v1/", + "https://w3id.org/reachhaven/harbour/core/v1/", { "JsonWebKey": "https://w3id.org/security#JsonWebKey", "publicKeyJwk": { diff --git a/examples/gaiax/README.md b/examples/gaiax/README.md index 4cb11c6..df10f6b 100644 --- a/examples/gaiax/README.md +++ b/examples/gaiax/README.md @@ -4,18 +4,52 @@ This directory contains the **complete Gaia-X credential storyline** — the end-to-end user journey from trust anchor through organization and employee onboarding to delegated blockchain transactions. +## Architecture + +**`harbour.gx:LegalPersonCredential` IS the compliance credential.** + +Holding a valid one means Haven has verified all three underlying Gaia-X VCs: + +1. ✅ `gx:LegalPerson` — entity identity verified +2. ✅ `gx:VatID` — registration number notary-checked +3. ✅ `gx:Issuer` — T&C accepted + +The three input VCs are **plain Gaia-X** (no harbour envelope type). The +`LegalPersonCredential` is the **compliance output** — Haven's stamp. + +The `harbour.gx:LegalPerson` SHACL shape enforces all three VC references +via `sh:minCount 1` — machine-readable enforcement that the Gaia-X Loire +specification is missing. + ## Structure -Credentials use the `harbour_gx:` namespace prefix -(`https://w3id.org/reachhaven/harbour/gaiax-domain/v1/`) for domain types +Credentials use the `harbour.gx:` namespace prefix +(`https://w3id.org/reachhaven/harbour/gx/v1/`) for domain types and properties, while core envelope types use `harbour:`. +### Input VCs (plain Gaia-X, no harbour envelope) + +| File | Issuer | Description | +|------|--------|-------------| +| `gx-legal-person.json` | Company (self-signed) | gx:LegalPerson self-description with name, addresses | +| `gx-registration-number.json` | Haven (notary) | gx:VatID with notary verification evidence | +| `gx-terms-and-conditions.json` | Company (self-signed) | gx:Issuer with T&C acceptance hash | + +### Output VCs (harbour compliance credentials) + +| File | Issuer | Description | +|------|--------|-------------| +| `legal-person-credential.json` | Haven (compliance) | Referenced pattern — compliance refs with digest hashes | +| `legal-person-credential-embedded.json` | Haven (compliance) | Embedded pattern — full gx VCs nested inline | + +### Other Credentials + | File | Step | Description | |------|------|-------------| | `trust-anchor-credential.json` | — | Trust Anchor self-signed credential (root of trust) | -| `legal-person-credential.json` | 1 | Organization credential with registration data | -| `natural-person-credential.json` | 2 | Employee credential with identity and `memberOf` link | -| `delegated-signing-receipt.json` | 3+4 | Transaction receipt with embedded consent VP as evidence | +| `participant-vp.json` | — | VP bundling all 4 VCs (3 plain gx + 1 compliance) | +| `natural-person-credential.json` | 5 | Employee credential with identity and `memberOf` link | +| `delegated-signing-receipt.json` | 6+7 | Transaction receipt with embedded consent VP as evidence | ## Context Stack @@ -25,8 +59,8 @@ All credentials use a stacked `@context` array: "@context": [ "https://www.w3.org/ns/credentials/v2", "https://w3id.org/gaia-x/development#", - "https://w3id.org/reachhaven/harbour/credentials/v1/", - "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/" + "https://w3id.org/reachhaven/harbour/core/v1/", + "https://w3id.org/reachhaven/harbour/gx/v1/" ] ``` diff --git a/examples/gaiax/delegated-signing-receipt.json b/examples/gaiax/delegated-signing-receipt.json index db65e95..3d6d523 100644 --- a/examples/gaiax/delegated-signing-receipt.json +++ b/examples/gaiax/delegated-signing-receipt.json @@ -1,8 +1,8 @@ { "@context": [ "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/reachhaven/harbour/credentials/v1/", - "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/" + "https://w3id.org/reachhaven/harbour/core/v1/", + "https://w3id.org/reachhaven/harbour/gx/v1/" ], "type": [ "VerifiableCredential", @@ -14,7 +14,7 @@ "credentialSubject": { "id": "urn:uuid:receipt-b7c8d9e0-f1a2-3456-789a-bcdef0123456", "type": "harbour:TransactionReceipt", - "transactionHash": "cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52", + "transactionHash": "c3d4ba771c1103935ab4121874c4b3a78c8471719c80f60d59ca5811e232089b", "blockchainTxId": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" }, "credentialStatus": [ @@ -43,8 +43,8 @@ { "@context": [ "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/reachhaven/harbour/credentials/v1/", - "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/" + "https://w3id.org/reachhaven/harbour/core/v1/", + "https://w3id.org/reachhaven/harbour/gx/v1/" ], "type": [ "VerifiableCredential" @@ -53,7 +53,7 @@ "validFrom": "2024-01-15T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", - "type": "harbour_gx:NaturalPerson", + "type": "harbour.gx:NaturalPerson", "memberOf": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" } } @@ -61,7 +61,7 @@ }, "delegatedTo": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", "transaction_data": { - "type": "harbour_delegate:data.purchase", + "type": "harbour.delegate:data.purchase", "credential_ids": [ "harbour_natural_person" ], @@ -77,7 +77,7 @@ "marketplace": "did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c" } }, - "challenge": "da9b1009 HARBOUR_DELEGATE cb9916944deeb764c7f78b4ade8f8466178824d58bbd0083734eba67818b1a52" + "challenge": "da9b1009 HARBOUR_DELEGATE c3d4ba771c1103935ab4121874c4b3a78c8471719c80f60d59ca5811e232089b" } ] } diff --git a/examples/gaiax/gx-legal-person.json b/examples/gaiax/gx-legal-person.json new file mode 100644 index 0000000..a4c030d --- /dev/null +++ b/examples/gaiax/gx-legal-person.json @@ -0,0 +1,39 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#", + { + "vcard": "http://www.w3.org/2006/vcard/ns#", + "schema": "http://schema.org/" + } + ], + "type": [ + "VerifiableCredential" + ], + "id": "urn:uuid:f1a2b3c4-d5e6-7890-abcd-ef1234567890", + "issuer": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", + "validFrom": "2024-01-15T00:00:00Z", + "validUntil": "2025-01-15T00:00:00Z", + "credentialSubject": { + "type": "gx:LegalPerson", + "id": "urn:uuid:f1a2b3c4-d5e6-7890-abcd-ef1234567890#cs", + "schema:name": "Example Corporation GmbH", + "gx:registrationNumber": { + "id": "urn:uuid:c3d4e5f6-a7b8-9012-cdef-123456789012#cs" + }, + "gx:headquartersAddress": { + "type": "gx:Address", + "gx:countryCode": "DE", + "vcard:street-address": "Musterstraße 42", + "vcard:locality": "München", + "vcard:postal-code": "80331" + }, + "gx:legalAddress": { + "type": "gx:Address", + "gx:countryCode": "DE", + "vcard:street-address": "Musterstraße 42", + "vcard:locality": "München", + "vcard:postal-code": "80331" + } + } +} diff --git a/examples/gaiax/gx-registration-number.json b/examples/gaiax/gx-registration-number.json new file mode 100644 index 0000000..466c055 --- /dev/null +++ b/examples/gaiax/gx-registration-number.json @@ -0,0 +1,28 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#" + ], + "type": [ + "VerifiableCredential" + ], + "id": "urn:uuid:c3d4e5f6-a7b8-9012-cdef-123456789012", + "name": "VAT ID", + "description": "Value Added Tax Identifier", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "validFrom": "2024-01-15T00:00:00Z", + "validUntil": "2024-07-15T00:00:00Z", + "credentialSubject": { + "type": "gx:VatID", + "id": "urn:uuid:c3d4e5f6-a7b8-9012-cdef-123456789012#cs", + "gx:vatID": "DE123456789", + "gx:countryCode": "DE" + }, + "evidence": [ + { + "gx:evidenceOf": "gx:VatID", + "gx:evidenceURL": "http://ec.europa.eu/taxation_customs/vies/services/checkVatService", + "gx:executionDate": "2024-01-15T00:00:00Z" + } + ] +} diff --git a/examples/gaiax/gx-terms-and-conditions.json b/examples/gaiax/gx-terms-and-conditions.json new file mode 100644 index 0000000..f6e1e56 --- /dev/null +++ b/examples/gaiax/gx-terms-and-conditions.json @@ -0,0 +1,18 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#" + ], + "type": [ + "VerifiableCredential" + ], + "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f12345678901", + "issuer": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", + "validFrom": "2024-01-15T00:00:00Z", + "validUntil": "2025-01-15T00:00:00Z", + "credentialSubject": { + "type": "gx:Issuer", + "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f12345678901#cs", + "gx:gaiaxTermsAndConditions": "4bd7554097444c960292b4726c2efa1373485e8a5565d94d41195214c5e0ceb3" + } +} diff --git a/examples/gaiax/legal-person-credential-embedded.json b/examples/gaiax/legal-person-credential-embedded.json new file mode 100644 index 0000000..8d6db7d --- /dev/null +++ b/examples/gaiax/legal-person-credential-embedded.json @@ -0,0 +1,87 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#", + "https://w3id.org/reachhaven/harbour/core/v1/", + "https://w3id.org/reachhaven/harbour/gx/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour.gx:LegalPersonCredential" + ], + "id": "urn:uuid:e1e2e3e4-e5e6-e7e8-e9e0-e1e2e3e4e5e6", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "validFrom": "2024-01-16T00:00:00Z", + "validUntil": "2025-01-16T00:00:00Z", + "credentialSubject": { + "id": "did:ethr:0x14a34:0xa2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1", + "type": "harbour.gx:LegalPerson", + "harbour.gx:compliantLegalPersonVC": { + "type": "harbour.gx:CompliantCredentialReference", + "harbour.gx:credentialType": "gx:LegalPerson", + "harbour.gx:digestSRI": "sha256-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", + "harbour.gx:embeddedCredential": "{\"@context\":[\"https://www.w3.org/ns/credentials/v2\",\"https://w3id.org/gaia-x/development#\",{\"vcard\":\"http://www.w3.org/2006/vcard/ns#\",\"schema\":\"http://schema.org/\"}],\"type\":[\"VerifiableCredential\"],\"id\":\"urn:uuid:e2e3e4e5-e6e7-e8e9-e0e1-e2e3e4e5e6e7\",\"issuer\":\"did:ethr:0x14a34:0xa2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1\",\"validFrom\":\"2024-01-15T00:00:00Z\",\"validUntil\":\"2025-01-15T00:00:00Z\",\"credentialSubject\":{\"type\":\"gx:LegalPerson\",\"id\":\"urn:uuid:e2e3e4e5-e6e7-e8e9-e0e1-e2e3e4e5e6e7#cs\",\"schema:name\":\"Example Corporation GmbH\",\"gx:registrationNumber\":{\"id\":\"urn:uuid:e3e4e5e6-e7e8-e9e0-e1e2-e3e4e5e6e7e8#cs\"},\"gx:headquartersAddress\":{\"type\":\"gx:Address\",\"gx:countryCode\":\"DE\",\"vcard:street-address\":\"Musterstra\\u00dfe 42\",\"vcard:locality\":\"M\\u00fcnchen\",\"vcard:postal-code\":\"80331\"},\"gx:legalAddress\":{\"type\":\"gx:Address\",\"gx:countryCode\":\"DE\",\"vcard:street-address\":\"Musterstra\\u00dfe 42\",\"vcard:locality\":\"M\\u00fcnchen\",\"vcard:postal-code\":\"80331\"}}}" + }, + "harbour.gx:compliantRegistrationVC": { + "type": "harbour.gx:CompliantCredentialReference", + "harbour.gx:credentialType": "gx:VatID", + "harbour.gx:digestSRI": "sha256-c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4", + "harbour.gx:embeddedCredential": "{\"@context\":[\"https://www.w3.org/ns/credentials/v2\",\"https://w3id.org/gaia-x/development#\"],\"type\":[\"VerifiableCredential\"],\"id\":\"urn:uuid:e3e4e5e6-e7e8-e9e0-e1e2-e3e4e5e6e7e8\",\"name\":\"VAT ID\",\"description\":\"Value Added Tax Identifier\",\"issuer\":\"did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697\",\"validFrom\":\"2024-01-15T00:00:00Z\",\"validUntil\":\"2024-07-15T00:00:00Z\",\"credentialSubject\":{\"type\":\"gx:VatID\",\"id\":\"urn:uuid:e3e4e5e6-e7e8-e9e0-e1e2-e3e4e5e6e7e8#cs\",\"gx:vatID\":\"DE123456789\",\"gx:countryCode\":\"DE\"},\"evidence\":{\"gx:evidenceOf\":\"gx:VatID\",\"gx:evidenceURL\":\"http://ec.europa.eu/taxation_customs/vies/services/checkVatService\",\"gx:executionDate\":\"2024-01-15T00:00:00Z\"}}" + }, + "harbour.gx:compliantTermsVC": { + "type": "harbour.gx:CompliantCredentialReference", + "harbour.gx:credentialType": "gx:Issuer", + "harbour.gx:digestSRI": "sha256-e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6", + "harbour.gx:embeddedCredential": "{\"@context\":[\"https://www.w3.org/ns/credentials/v2\",\"https://w3id.org/gaia-x/development#\"],\"type\":[\"VerifiableCredential\"],\"id\":\"urn:uuid:e4e5e6e7-e8e9-e0e1-e2e3-e4e5e6e7e8e9\",\"issuer\":\"did:ethr:0x14a34:0xa2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1\",\"validFrom\":\"2024-01-15T00:00:00Z\",\"validUntil\":\"2025-01-15T00:00:00Z\",\"credentialSubject\":{\"type\":\"gx:Issuer\",\"id\":\"urn:uuid:e4e5e6e7-e8e9-e0e1-e2e3-e4e5e6e7e8e9#cs\",\"gx:gaiaxTermsAndConditions\":\"4bd7554097444c960292b4726c2efa1373485e8a5565d94d41195214c5e0ceb3\"}}" + }, + "harbour.gx:labelLevel": "SC", + "harbour.gx:engineVersion": "2.11.0", + "harbour.gx:rulesVersion": "CD25.10", + "harbour.gx:validatedCriteria": [ + "https://docs.gaia-x.eu/policy-rules-committee/compliance-document/25.10/criteria_participant/#PA1.1" + ] + }, + "credentialStatus": [ + { + "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/e1e2e3e4e5e6e7e8", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ], + "evidence": [ + { + "type": [ + "harbour:Evidence", + "harbour:CredentialEvidence" + ], + "verifiablePresentation": { + "@context": [ + "https://www.w3.org/ns/credentials/v2" + ], + "type": [ + "VerifiablePresentation", + "harbour:VerifiablePresentation" + ], + "holder": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/core/v1/" + ], + "type": [ + "VerifiableCredential" + ], + "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "type": "harbour:LinkedCredentialService", + "didcore:serviceEndpoint": {"id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3"} + } + } + ] + } + } + ] +} diff --git a/examples/gaiax/legal-person-credential.json b/examples/gaiax/legal-person-credential.json index 1d20bf8..23cf932 100644 --- a/examples/gaiax/legal-person-credential.json +++ b/examples/gaiax/legal-person-credential.json @@ -2,37 +2,41 @@ "@context": [ "https://www.w3.org/ns/credentials/v2", "https://w3id.org/gaia-x/development#", - "https://w3id.org/reachhaven/harbour/credentials/v1/", - "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/" + "https://w3id.org/reachhaven/harbour/core/v1/", + "https://w3id.org/reachhaven/harbour/gx/v1/" ], "type": [ "VerifiableCredential", - "harbour_gx:LegalPersonCredential" + "harbour.gx:LegalPersonCredential" ], "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", - "validFrom": "2024-01-15T00:00:00Z", - "validUntil": "2025-01-15T00:00:00Z", + "validFrom": "2024-01-16T00:00:00Z", + "validUntil": "2025-01-16T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", - "type": "harbour_gx:LegalPerson", - "name": "Example Corporation GmbH", - "registrationNumber": { - "type": "gx:RegistrationNumber", - "taxID": "DE123456789" + "type": "harbour.gx:LegalPerson", + "harbour.gx:compliantLegalPersonVC": { + "type": "harbour.gx:CompliantCredentialReference", + "harbour.gx:credentialType": "gx:LegalPerson", + "harbour.gx:digestSRI": "sha256-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" }, - "headquartersAddress": { - "type": "gx:Address", - "countryCode": "DE", - "countryName": "Germany", - "locality": "Munich" + "harbour.gx:compliantRegistrationVC": { + "type": "harbour.gx:CompliantCredentialReference", + "harbour.gx:credentialType": "gx:VatID", + "harbour.gx:digestSRI": "sha256-c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4" }, - "legalAddress": { - "type": "gx:Address", - "countryCode": "DE", - "countryName": "Germany", - "locality": "Munich" - } + "harbour.gx:compliantTermsVC": { + "type": "harbour.gx:CompliantCredentialReference", + "harbour.gx:credentialType": "gx:Issuer", + "harbour.gx:digestSRI": "sha256-e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6" + }, + "harbour.gx:labelLevel": "SC", + "harbour.gx:engineVersion": "2.11.0", + "harbour.gx:rulesVersion": "CD25.10", + "harbour.gx:validatedCriteria": [ + "https://docs.gaia-x.eu/policy-rules-committee/compliance-document/25.10/criteria_participant/#PA1.1" + ] }, "credentialStatus": [ { @@ -60,9 +64,7 @@ { "@context": [ "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/gaia-x/development#", - "https://w3id.org/reachhaven/harbour/credentials/v1/", - "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/" + "https://w3id.org/reachhaven/harbour/core/v1/" ], "type": [ "VerifiableCredential" @@ -71,20 +73,8 @@ "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", - "type": "harbour_gx:LegalPerson", - "name": "ReachHaven GmbH", - "registrationNumber": { - "type": "gx:RegistrationNumber", - "taxID": "DE987654321" - }, - "headquartersAddress": { - "type": "gx:Address", - "countryCode": "DE" - }, - "legalAddress": { - "type": "gx:Address", - "countryCode": "DE" - } + "type": "harbour:LinkedCredentialService", + "didcore:serviceEndpoint": {"id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3"} } } ] diff --git a/examples/gaiax/natural-person-credential.json b/examples/gaiax/natural-person-credential.json index 0a2814b..c06c922 100644 --- a/examples/gaiax/natural-person-credential.json +++ b/examples/gaiax/natural-person-credential.json @@ -2,12 +2,12 @@ "@context": [ "https://www.w3.org/ns/credentials/v2", "https://w3id.org/gaia-x/development#", - "https://w3id.org/reachhaven/harbour/credentials/v1/", - "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/" + "https://w3id.org/reachhaven/harbour/core/v1/", + "https://w3id.org/reachhaven/harbour/gx/v1/" ], "type": [ "VerifiableCredential", - "harbour_gx:NaturalPersonCredential" + "harbour.gx:NaturalPersonCredential" ], "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f23456789012", "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", @@ -15,7 +15,7 @@ "validUntil": "2025-01-15T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", - "type": "harbour_gx:NaturalPerson", + "type": "harbour.gx:NaturalPerson", "givenName": "Alice", "familyName": "Smith", "email": "alice.smith@example.com", @@ -42,7 +42,10 @@ ], "verifiablePresentation": { "@context": [ - "https://www.w3.org/ns/credentials/v2" + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#", + "https://w3id.org/reachhaven/harbour/core/v1/", + "https://w3id.org/reachhaven/harbour/gx/v1/" ], "type": [ "VerifiablePresentation", @@ -54,19 +57,86 @@ "@context": [ "https://www.w3.org/ns/credentials/v2", "https://w3id.org/gaia-x/development#", - "https://w3id.org/reachhaven/harbour/credentials/v1/", - "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/" + "https://w3id.org/reachhaven/harbour/core/v1/", + "https://w3id.org/reachhaven/harbour/gx/v1/" ], "type": [ - "VerifiableCredential" + "VerifiableCredential", + "harbour.gx:LegalPersonCredential" ], + "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567899", "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", - "validFrom": "2024-01-01T00:00:00Z", + "validFrom": "2024-01-16T00:00:00Z", + "validUntil": "2025-01-16T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", - "type": "harbour_gx:LegalPerson", - "name": "Example Corporation GmbH" - } + "id": "did:ethr:0x14a34:0xb1b2b3b4b5b6b7b8b9b0b1b2b3b4b5b6b7b8b9b0", + "type": "harbour.gx:LegalPerson", + "harbour.gx:compliantLegalPersonVC": { + "type": "harbour.gx:CompliantCredentialReference", + "harbour.gx:credentialType": "gx:LegalPerson", + "harbour.gx:digestSRI": "sha256-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + }, + "harbour.gx:compliantRegistrationVC": { + "type": "harbour.gx:CompliantCredentialReference", + "harbour.gx:credentialType": "gx:VatID", + "harbour.gx:digestSRI": "sha256-c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4" + }, + "harbour.gx:compliantTermsVC": { + "type": "harbour.gx:CompliantCredentialReference", + "harbour.gx:credentialType": "gx:Issuer", + "harbour.gx:digestSRI": "sha256-e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6" + }, + "harbour.gx:labelLevel": "SC", + "harbour.gx:engineVersion": "2.11.0", + "harbour.gx:rulesVersion": "CD25.10", + "harbour.gx:validatedCriteria": "Gaia-X Trust Framework v25.10" + }, + "credentialStatus": [ + { + "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/a1b2c3d4e5f67899", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ], + "evidence": [ + { + "type": [ + "harbour:Evidence", + "harbour:CredentialEvidence" + ], + "verifiablePresentation": { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/core/v1/" + ], + "type": [ + "VerifiablePresentation", + "harbour:VerifiablePresentation" + ], + "holder": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/core/v1/" + ], + "type": [ + "VerifiableCredential" + ], + "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "type": "harbour:LinkedCredentialService", + "didcore:serviceEndpoint": { + "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3" + } + } + } + ] + } + } + ] } ] } diff --git a/examples/gaiax/participant-vp.json b/examples/gaiax/participant-vp.json new file mode 100644 index 0000000..face334 --- /dev/null +++ b/examples/gaiax/participant-vp.json @@ -0,0 +1,182 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#", + "https://w3id.org/reachhaven/harbour/core/v1/", + "https://w3id.org/reachhaven/harbour/gx/v1/" + ], + "type": [ + "VerifiablePresentation", + "harbour:VerifiablePresentation" + ], + "holder": "did:ethr:0x14a34:0xd1d2d3d4d5d6d7d8d9d0d1d2d3d4d5d6d7d8d9d0", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#", + { + "vcard": "http://www.w3.org/2006/vcard/ns#", + "schema": "http://schema.org/" + } + ], + "type": [ + "VerifiableCredential" + ], + "id": "urn:uuid:f1a2b3c4-d5e6-7890-abcd-ef1234567899", + "issuer": "did:ethr:0x14a34:0xd1d2d3d4d5d6d7d8d9d0d1d2d3d4d5d6d7d8d9d0", + "validFrom": "2024-01-15T00:00:00Z", + "validUntil": "2025-01-15T00:00:00Z", + "credentialSubject": { + "type": "gx:LegalPerson", + "id": "urn:uuid:f1a2b3c4-d5e6-7890-abcd-ef1234567899#cs", + "schema:name": "Example Corporation GmbH", + "gx:registrationNumber": { + "id": "urn:uuid:c3d4e5f6-a7b8-9012-cdef-123456789019#cs" + }, + "gx:headquartersAddress": { + "type": "gx:Address", + "gx:countryCode": "DE", + "vcard:street-address": "Musterstra\u00dfe 42", + "vcard:locality": "M\u00fcnchen", + "vcard:postal-code": "80331" + }, + "gx:legalAddress": { + "type": "gx:Address", + "gx:countryCode": "DE", + "vcard:street-address": "Musterstra\u00dfe 42", + "vcard:locality": "M\u00fcnchen", + "vcard:postal-code": "80331" + } + } + }, + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#" + ], + "type": [ + "VerifiableCredential" + ], + "id": "urn:uuid:c3d4e5f6-a7b8-9012-cdef-123456789019", + "name": "VAT ID", + "description": "Value Added Tax Identifier", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "validFrom": "2024-01-15T00:00:00Z", + "validUntil": "2024-07-15T00:00:00Z", + "credentialSubject": { + "type": "gx:VatID", + "id": "urn:uuid:c3d4e5f6-a7b8-9012-cdef-123456789019#cs", + "gx:vatID": "DE123456789", + "gx:countryCode": "DE" + }, + "evidence": { + "gx:evidenceOf": "gx:VatID", + "gx:evidenceURL": "http://ec.europa.eu/taxation_customs/vies/services/checkVatService", + "gx:executionDate": "2024-01-15T00:00:00Z" + } + }, + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#" + ], + "type": [ + "VerifiableCredential" + ], + "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f12345678909", + "issuer": "did:ethr:0x14a34:0xd1d2d3d4d5d6d7d8d9d0d1d2d3d4d5d6d7d8d9d0", + "validFrom": "2024-01-15T00:00:00Z", + "validUntil": "2025-01-15T00:00:00Z", + "credentialSubject": { + "type": "gx:Issuer", + "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f12345678909#cs", + "gx:gaiaxTermsAndConditions": "4bd7554097444c960292b4726c2efa1373485e8a5565d94d41195214c5e0ceb3" + } + }, + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#", + "https://w3id.org/reachhaven/harbour/core/v1/", + "https://w3id.org/reachhaven/harbour/gx/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour.gx:LegalPersonCredential" + ], + "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "validFrom": "2024-01-16T00:00:00Z", + "validUntil": "2025-01-16T00:00:00Z", + "credentialSubject": { + "id": "did:ethr:0x14a34:0xd1d2d3d4d5d6d7d8d9d0d1d2d3d4d5d6d7d8d9d0", + "type": "harbour.gx:LegalPerson", + "harbour.gx:compliantLegalPersonVC": { + "type": "harbour.gx:CompliantCredentialReference", + "harbour.gx:credentialType": "gx:LegalPerson", + "harbour.gx:digestSRI": "sha256-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + }, + "harbour.gx:compliantRegistrationVC": { + "type": "harbour.gx:CompliantCredentialReference", + "harbour.gx:credentialType": "gx:VatID", + "harbour.gx:digestSRI": "sha256-c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4" + }, + "harbour.gx:compliantTermsVC": { + "type": "harbour.gx:CompliantCredentialReference", + "harbour.gx:credentialType": "gx:Issuer", + "harbour.gx:digestSRI": "sha256-e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6" + }, + "harbour.gx:labelLevel": "SC", + "harbour.gx:engineVersion": "2.11.0", + "harbour.gx:rulesVersion": "CD25.10", + "harbour.gx:validatedCriteria": [ + "https://docs.gaia-x.eu/policy-rules-committee/compliance-document/25.10/criteria_participant/#PA1.1" + ] + }, + "credentialStatus": [ + { + "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/a1b2c3d4e5f67890", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ], + "evidence": [ + { + "type": [ + "harbour:Evidence", + "harbour:CredentialEvidence" + ], + "verifiablePresentation": { + "@context": [ + "https://www.w3.org/ns/credentials/v2" + ], + "type": [ + "VerifiablePresentation", + "harbour:VerifiablePresentation" + ], + "holder": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/core/v1/" + ], + "type": [ + "VerifiableCredential" + ], + "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "type": "harbour:LinkedCredentialService", + "didcore:serviceEndpoint": {"id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3"} + } + } + ] + } + } + ] + } + ] +} diff --git a/examples/gaiax/trust-anchor-credential.json b/examples/gaiax/trust-anchor-credential.json index 9953b33..5e06624 100644 --- a/examples/gaiax/trust-anchor-credential.json +++ b/examples/gaiax/trust-anchor-credential.json @@ -1,7 +1,7 @@ { "@context": [ "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/reachhaven/harbour/credentials/v1/" + "https://w3id.org/reachhaven/harbour/core/v1/" ], "type": [ "VerifiableCredential", @@ -11,7 +11,9 @@ "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3" + "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "type": "harbour:LinkedCredentialService", + "didcore:serviceEndpoint": {"id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3"} }, "credentialStatus": [ { diff --git a/examples/gaiax_external/Gaia-X_Loire Participant Credential VC_(2026)_signed.json b/examples/gaiax_external/Gaia-X_Loire Participant Credential VC_(2026)_signed.json new file mode 100644 index 0000000..6334f55 --- /dev/null +++ b/examples/gaiax_external/Gaia-X_Loire Participant Credential VC_(2026)_signed.json @@ -0,0 +1,43 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#" + ], + "type": [ + "VerifiableCredential" + ], + "id": "https://gaia-x.eu/.well-known/compliance-credential.jwt", + "issuer": "did:web:compliance.gxdch.gaiax.ovh:v2", + "validFrom": "2026-03-05T09:00:04.830Z", + "validUntil": "2026-06-03T09:00:04.824Z", + "credentialSubject": { + "type": "gx:LabelCredential", + "id": "https://gaia-x.eu/.well-known/compliance-credential.jwt#cs", + "gx:labelLevel": "SC", + "gx:engineVersion": "2.11.0", + "gx:rulesVersion": "CD25.10", + "gx:compliantCredentials": [ + { + "type": "gx:CompliantCredential", + "id": "https://gaia-x.eu/.well-known/compliance-credential.jwt#compliant-1", + "gx:credentialType": "gx:LegalPerson", + "digestSRI": "sha256-29784869cbb4b2970085ab3d22bd1fc732faabdd55f27f999d7f95ad7fc4b5a9" + }, + { + "type": "gx:CompliantCredential", + "id": "https://gaia-x.eu/.well-known/compliance-credential.jwt#compliant-2", + "gx:credentialType": "gx:Issuer", + "digestSRI": "sha256-d2ca1a537ba796a53d724d99370ccf160dcea7fc8dfbcec1fbc9db4de3fdef6c" + }, + { + "type": "gx:CompliantCredential", + "id": "https://gaia-x.eu/.well-known/compliance-credential.jwt#compliant-3", + "gx:credentialType": "gx:VatID", + "digestSRI": "sha256-07532c26c70a0caf97d6a0991e190641637e1a1522011334f3cd67d51791b5a9" + } + ], + "gx:validatedCriteria": [ + "https://docs.gaia-x.eu/policy-rules-committee/compliance-document/25.10/criteria_participant/#PA1.1" + ] + } +} diff --git a/examples/gaiax_external/Gaia-X_Loire Participant Credential VC_(2026)_signed.jwt b/examples/gaiax_external/Gaia-X_Loire Participant Credential VC_(2026)_signed.jwt new file mode 100644 index 0000000..5032fab --- /dev/null +++ b/examples/gaiax_external/Gaia-X_Loire Participant Credential VC_(2026)_signed.jwt @@ -0,0 +1 @@ +eyJhbGciOiJQUzI1NiIsImlzcyI6ImRpZDp3ZWI6Y29tcGxpYW5jZS5neGRjaC5nYWlheC5vdmg6djIiLCJraWQiOiJkaWQ6d2ViOmNvbXBsaWFuY2UuZ3hkY2guZ2FpYXgub3ZoOnYyI1g1MDktSldLIiwiaWF0IjoxNzcyNzAxMjA0ODMwLCJleHAiOjE3ODA0NzcyMDQ4MjQsImN0eSI6InZjIiwidHlwIjoidmMrand0In0.eyJAY29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvbnMvY3JlZGVudGlhbHMvdjIiLCJodHRwczovL3czaWQub3JnL2dhaWEteC9kZXZlbG9wbWVudCMiXSwidHlwZSI6WyJWZXJpZmlhYmxlQ3JlZGVudGlhbCIsImd4OkxhYmVsQ3JlZGVudGlhbCJdLCJpZCI6Imh0dHBzOi8vZ2FpYS14LmV1Ly53ZWxsLWtub3duL2NvbXBsaWFuY2UtY3JlZGVudGlhbC5qd3QiLCJpc3N1ZXIiOiJkaWQ6d2ViOmNvbXBsaWFuY2UuZ3hkY2guZ2FpYXgub3ZoOnYyIiwidmFsaWRGcm9tIjoiMjAyNi0wMy0wNVQwOTowMDowNC44MzBaIiwidmFsaWRVbnRpbCI6IjIwMjYtMDYtMDNUMDk6MDA6MDQuODI0WiIsImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiaHR0cHM6Ly9nYWlhLXguZXUvLndlbGwta25vd24vY29tcGxpYW5jZS1jcmVkZW50aWFsLmp3dCNjcyIsImd4OmxhYmVsTGV2ZWwiOiJTQyIsImd4OmVuZ2luZVZlcnNpb24iOiIyLjExLjAiLCJneDpydWxlc1ZlcnNpb24iOiJDRDI1LjEwIiwiZ3g6Y29tcGxpYW50Q3JlZGVudGlhbHMiOlt7ImlkIjoiaHR0cHM6Ly9nYWlhLXguZXUvLndlbGwta25vd24vbGVnYWwtcGVyc29uLmpzb24iLCJ0eXBlIjoiZ3g6TGVnYWxQZXJzb24iLCJneDpkaWdlc3RTUkkiOiJzaGEyNTYtMjk3ODQ4NjljYmI0YjI5NzAwODVhYjNkMjJiZDFmYzczMmZhYWJkZDU1ZjI3Zjk5OWQ3Zjk1YWQ3ZmM0YjVhOSJ9LHsiaWQiOiJodHRwczovL2dhaWEteC5ldS8ud2VsbC1rbm93bi90ZXJtcy1hbmQtY29uZGl0aW9ucy5qc29uIiwidHlwZSI6Imd4Oklzc3VlciIsImd4OmRpZ2VzdFNSSSI6InNoYTI1Ni1kMmNhMWE1MzdiYTc5NmE1M2Q3MjRkOTkzNzBjY2YxNjBkY2VhN2ZjOGRmYmNlYzFmYmM5ZGI0ZGUzZmRlZjZjIn0seyJpZCI6Imh0dHBzOi8vZ2FpYS14LmV1Ly53ZWxsLWtub3duL3ZhdElELmpzb24iLCJ0eXBlIjoiZ3g6VmF0SUQiLCJneDpkaWdlc3RTUkkiOiJzaGEyNTYtMDc1MzJjMjZjNzBhMGNhZjk3ZDZhMDk5MWUxOTA2NDE2MzdlMWExNTIyMDExMzM0ZjNjZDY3ZDUxNzkxYjVhOSJ9XSwiZ3g6dmFsaWRhdGVkQ3JpdGVyaWEiOlsiaHR0cHM6Ly9kb2NzLmdhaWEteC5ldS9wb2xpY3ktcnVsZXMtY29tbWl0dGVlL2NvbXBsaWFuY2UtZG9jdW1lbnQvMjUuMTAvY3JpdGVyaWFfcGFydGljaXBhbnQvI1BBMS4xIl19fQ.im8Sl7fqr2IqzKL6O8uaMEEiBgDJ-5NhtjwGLwfDznbwD5KQ-UezCD4zCTNS_IHzUpx0yqbn4Htkhvd9ZPO7Z3uV9Mv2xN65In20WPS2UMTcsjmjF4aci-r9wCj_6p3-K4YePlJdPs-bXZ-Qm2pCrQJNsv3j0FE0kYyI_beQXJy8GV2VYyzFKWL7Wa3Pz2azJE0Ns9Ve1sqTEfGe6RyxOgQdqxtxGMfEEJMBlKGPLPedr8DifikcY9J3wi9ParLKSTG4QOnX7sGbuGJbjW74uDgIvc9k1Xa2Vg99fm3ErljWXAMOR4nRY4eZuGtr8caN2sT04a2TgZ0artJn4BfucdLTvBKJpFsxUyDVZdYtoMlXRhAj8UUAU15DA62v_r3wpBhqK32cC_AqenTkJEniiz9IzY-p9pjS9ojM9I6r3iVa8bAZvjUMuMZjRgcWg0KaPUFJAkboiAe_6BgIuwkZ-5vYTbpB_RQ5T7Dvd2M5xuza6CYygUUo7PPAk1kjMuNnrKEUliSyKhuURlNe_ydZc12YPlQm0r_NHRMzjd4_0MVX8SVjO6g6ITrWiSNWaNcIGGW5BaG0eJqIa3YjH6zIx-GAguPtwERYPnueFiGMOOXQgLJe6FNcqXeS02tpVtiEhUY5m9aw_QhQWjW2kK2EpmgFQ1R31iFgmExXUJV4x9E \ No newline at end of file diff --git a/examples/gaiax_external/Gaia-X_Loire Participant Credential VP_(2026).json b/examples/gaiax_external/Gaia-X_Loire Participant Credential VP_(2026).json new file mode 100644 index 0000000..9861ce8 --- /dev/null +++ b/examples/gaiax_external/Gaia-X_Loire Participant Credential VP_(2026).json @@ -0,0 +1,95 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://www.w3.org/ns/credentials/examples/v2" + ], + "type": "VerifiablePresentation", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#", + { + "vcard": "http://www.w3.org/2006/vcard/ns#", + "schema": "http://schema.org/" + } + ], + "type": [ + "VerifiableCredential" + ], + "id": "https://gaia-x.eu/.well-known/legal-person.json", + "issuer": "did:web:gaia-x.eu", + "credentialSubject": { + "type": "gx:LegalPerson", + "id": "https://gaia-x.eu/.well-known/legal-person.json#cs", + "schema:name": "Gaia-X European Association for Data and Cloud AISBL", + "gx:registrationNumber": { + "id": "https://gaia-x.eu/.well-known/vatID.json#cs" + }, + "gx:headquartersAddress": { + "type": "gx:Address", + "gx:countryCode": "BE", + "vcard:street-address": "6-9 Avenue des Arts", + "vcard:locality": "Brussels", + "vcard:postal-code": "1210" + }, + "gx:legalAddress": { + "type": "gx:Address", + "gx:countryCode": "BE", + "vcard:street-address": "6-9 Avenue des Arts", + "vcard:locality": "Brussels", + "vcard:postal-code": "1210" + } + }, + "validFrom": "2026-03-05T09:58:45.603+01:00", + "validUntil": "2027-03-05T09:58:45.603+01:00" + }, + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#" + ], + "type": [ + "VerifiableCredential" + ], + "id": "https://gaia-x.eu/.well-known/vatID.json", + "name": "VAT ID", + "description": "Value Added Tax Identifier", + "issuer": "did:web:notary.gxdch.gaiax.ovh:v2", + "validFrom": "2026-03-05T08:56:01.847+00:00", + "validUntil": "2026-06-03T08:56:01.849+00:00", + "credentialSubject": { + "type": "gx:VatID", + "id": "https://gaia-x.eu/.well-known/vatID.json#cs", + "gx:vatID": "BE0762747721", + "gx:countryCode": "BE" + }, + "evidence": { + "gx:evidenceOf": "gx:VatID", + "gx:evidenceURL": "http://ec.europa.eu/taxation_customs/vies/services/checkVatService", + "gx:executionDate": "2026-03-05T08:56:01.846+00:00" + } + }, + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/gaia-x/development#" + ], + "type": [ + "VerifiableCredential" + ], + "id": "https://gaia-x.eu/.well-known/terms-and-conditions.json", + "issuer": "did:web:gaia-x.eu", + "credentialSubject": { + "type": "gx:Issuer", + "id": "https://gaia-x.eu/.well-known/terms-and-conditions.json#cs", + "gx:gaiaxTermsAndConditions": "4bd7554097444c960292b4726c2efa1373485e8a5565d94d41195214c5e0ceb3" + }, + "validFrom": "2026-03-05T09:58:45.604+01:00", + "validUntil": "2027-03-05T09:58:45.604+01:00" + } + ], + "issuer": "did:web:gaia-x.eu", + "validFrom": "2025-10-06T10:53:00.517+02:00", + "validUntil": "2026-01-04T10:53:00.517+02:00" +} diff --git a/examples/gaiax_external/Gaia-X_Loire Participant Credential VP_(2026)_signed.jwt b/examples/gaiax_external/Gaia-X_Loire Participant Credential VP_(2026)_signed.jwt new file mode 100644 index 0000000..209eb33 --- /dev/null +++ b/examples/gaiax_external/Gaia-X_Loire Participant Credential VP_(2026)_signed.jwt @@ -0,0 +1 @@ +eyJhbGciOiJFUzI1NiIsInR5cCI6InZwK2p3dCIsImN0eSI6InZwIiwiaXNzIjoiZGlkOndlYjpnYWlhLXguZXUiLCJraWQiOiJkaWQ6d2ViOmdhaWEteC5ldSNrZXktMCJ9.eyJAY29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvbnMvY3JlZGVudGlhbHMvdjIiLCJodHRwczovL3d3dy53My5vcmcvbnMvY3JlZGVudGlhbHMvZXhhbXBsZXMvdjIiXSwidHlwZSI6IlZlcmlmaWFibGVQcmVzZW50YXRpb24iLCJ2ZXJpZmlhYmxlQ3JlZGVudGlhbCI6W3siQGNvbnRleHQiOiJodHRwczovL3d3dy53My5vcmcvbnMvY3JlZGVudGlhbHMvdjIiLCJpZCI6ImRhdGE6YXBwbGljYXRpb24vdmMrand0LGV5SmhiR2NpT2lKRlV6STFOaUlzSW5SNWNDSTZJblpqSzJwM2RDSXNJbU4wZVNJNkluWmpJaXdpYVhOeklqb2laR2xrT25kbFlqcG5ZV2xoTFhndVpYVWlMQ0pyYVdRaU9pSmthV1E2ZDJWaU9tZGhhV0V0ZUM1bGRTTnJaWGt0TUNKOS5leUpBWTI5dWRHVjRkQ0k2V3lKb2RIUndjem92TDNkM2R5NTNNeTV2Y21jdmJuTXZZM0psWkdWdWRHbGhiSE12ZGpJaUxDSm9kSFJ3Y3pvdkwzY3phV1F1YjNKbkwyZGhhV0V0ZUM5a1pYWmxiRzl3YldWdWRDTWlMSHNpZG1OaGNtUWlPaUpvZEhSd09pOHZkM2QzTG5jekxtOXlaeTh5TURBMkwzWmpZWEprTDI1ekl5SXNJbk5qYUdWdFlTSTZJbWgwZEhCek9pOHZjMk5vWlcxaExtOXlaeThpZlYwc0ltbGtJam9pYUhSMGNITTZMeTluWVdsaExYZ3VaWFV2TG5kbGJHd3RhMjV2ZDI0dmJHVm5ZV3d0Y0dWeWMyOXVMbXB6YjI0aUxDSjBlWEJsSWpwYklsWmxjbWxtYVdGaWJHVkRjbVZrWlc1MGFXRnNJaXdpWjNnNlRHVm5ZV3hRWlhKemIyNGlYU3dpYVhOemRXVnlJam9pWkdsa09uZGxZanBuWVdsaExYZ3VaWFVpTENKamNtVmtaVzUwYVdGc1UzVmlhbVZqZENJNmV5SnpZMmhsYldFNmJtRnRaU0k2SWtkaGFXRXRXQ0JGZFhKdmNHVmhiaUJCYzNOdlkybGhkR2x2YmlCbWIzSWdSR0YwWVNCaGJtUWdRMnh2ZFdRZ1FVbFRRa3dpTENKbmVEcHlaV2RwYzNSeVlYUnBiMjVPZFcxaVpYSWlPbnNpYVdRaU9pSm9kSFJ3Y3pvdkwyZGhhV0V0ZUM1bGRTOHVkMlZzYkMxcmJtOTNiaTkyWVhSSlJDNXFjMjl1STJOekluMHNJbWQ0T21obFlXUnhkV0Z5ZEdWeWMwRmtaSEpsYzNNaU9uc2lkSGx3WlNJNkltZDRPa0ZrWkhKbGMzTWlMQ0puZURwamIzVnVkSEo1UTI5a1pTSTZJa0pGSWl3aWRtTmhjbVE2YzNSeVpXVjBMV0ZrWkhKbGMzTWlPaUkyTFRrZ1FYWmxiblZsSUdSbGN5QkJjblJ6SWl3aWRtTmhjbVE2Ykc5allXeHBkSGtpT2lKQ2NuVnpjMlZzY3lJc0luWmpZWEprT25CdmMzUmhiQzFqYjJSbElqb2lNVEl4TUNKOUxDSm5lRHBzWldkaGJFRmtaSEpsYzNNaU9uc2lkSGx3WlNJNkltZDRPa0ZrWkhKbGMzTWlMQ0puZURwamIzVnVkSEo1UTI5a1pTSTZJa0pGSWl3aWRtTmhjbVE2YzNSeVpXVjBMV0ZrWkhKbGMzTWlPaUkyTFRrZ1FYWmxiblZsSUdSbGN5QkJjblJ6SWl3aWRtTmhjbVE2Ykc5allXeHBkSGtpT2lKQ2NuVnpjMlZzY3lJc0luWmpZWEprT25CdmMzUmhiQzFqYjJSbElqb2lNVEl4TUNKOUxDSnBaQ0k2SW1oMGRIQnpPaTh2WjJGcFlTMTRMbVYxTHk1M1pXeHNMV3R1YjNkdUwyeGxaMkZzTFhCbGNuTnZiaTVxYzI5dUkyTnpJbjBzSW5aaGJHbGtSbkp2YlNJNklqSXdNall0TURNdE1EVlVNRGs2TlRnNk5EVXVOakF6S3pBeE9qQXdJaXdpZG1Gc2FXUlZiblJwYkNJNklqSXdNamN0TURNdE1EVlVNRGs2TlRnNk5EVXVOakF6S3pBeE9qQXdJbjAueS1EQnBibWM3QUFDLUQyVC01Rmk2bnduZGZyaUpyZGNwUU9JRi1tMFpJVUhldUpuaUhXemJQU1g3X3NSVXV1aVAybHFUX3ZqWWFkaDlTQ1QwMGR5R2ciLCJ0eXBlIjoiRW52ZWxvcGVkVmVyaWZpYWJsZUNyZWRlbnRpYWwifSx7IkBjb250ZXh0IjoiaHR0cHM6Ly93d3cudzMub3JnL25zL2NyZWRlbnRpYWxzL3YyIiwiaWQiOiJkYXRhOmFwcGxpY2F0aW9uL3ZjK2p3dCxleUpoYkdjaU9pSkZVekkxTmlJc0luUjVjQ0k2SW5aaksycDNkQ0lzSW1OMGVTSTZJblpqSWl3aWFYTnpJam9pWkdsa09uZGxZanBuWVdsaExYZ3VaWFVpTENKcmFXUWlPaUprYVdRNmQyVmlPbWRoYVdFdGVDNWxkU05yWlhrdE1DSjkuZXlKQVkyOXVkR1Y0ZENJNld5Sm9kSFJ3Y3pvdkwzZDNkeTUzTXk1dmNtY3Zibk12WTNKbFpHVnVkR2xoYkhNdmRqSWlMQ0pvZEhSd2N6b3ZMM2N6YVdRdWIzSm5MMmRoYVdFdGVDOWtaWFpsYkc5d2JXVnVkQ01pWFN3aWRIbHdaU0k2V3lKV1pYSnBabWxoWW14bFEzSmxaR1Z1ZEdsaGJDSXNJbWQ0T2tsemMzVmxjaUpkTENKcGMzTjFaWElpT2lKa2FXUTZkMlZpT21kaGFXRXRlQzVsZFNJc0ltbGtJam9pYUhSMGNITTZMeTluWVdsaExYZ3VaWFV2TG5kbGJHd3RhMjV2ZDI0dmRHVnliWE10WVc1a0xXTnZibVJwZEdsdmJuTXVhbk52YmlJc0ltTnlaV1JsYm5ScFlXeFRkV0pxWldOMElqcDdJbWxrSWpvaWFIUjBjSE02THk5bllXbGhMWGd1WlhVdkxuZGxiR3d0YTI1dmQyNHZkR1Z5YlhNdFlXNWtMV052Ym1ScGRHbHZibk11YW5OdmJpTmpjeUlzSW1kaGFXRjRWR1Z5YlhOQmJtUkRiMjVrYVhScGIyNXpJam9pTkdKa056VTFOREE1TnpRME5HTTVOakF5T1RKaU5EY3lObU15WldaaE1UTTNNelE0TldVNFlUVTFOalZrT1RSa05ERXhPVFV5TVRSak5XVXdZMlZpTXlKOUxDSjJZV3hwWkVaeWIyMGlPaUl5TURJMkxUQXpMVEExVkRBNU9qVTRPalExTGpZd05Dc3dNVG93TUNJc0luWmhiR2xrVlc1MGFXd2lPaUl5TURJM0xUQXpMVEExVkRBNU9qVTRPalExTGpZd05Dc3dNVG93TUNKOS5yR055bGxFMUxhS2NMRmp3Q05qazdXZ1U2SUdVQnNPajNLMHN4Qzc2dFJCYkhIOUZGcTRYZFN5MHllM1VqV21LSFRqdzRjbHBKVHNqdkFpT29mOEg4QSIsInR5cGUiOiJFbnZlbG9wZWRWZXJpZmlhYmxlQ3JlZGVudGlhbCJ9LHsiQGNvbnRleHQiOiJodHRwczovL3d3dy53My5vcmcvbnMvY3JlZGVudGlhbHMvdjIiLCJpZCI6ImRhdGE6YXBwbGljYXRpb24vdmMrand0LGV5SmhiR2NpT2lKUVV6STFOaUlzSW1semN5STZJbVJwWkRwM1pXSTZibTkwWVhKNUxtZDRaR05vTG1kaGFXRjRMbTkyYURwMk1pSXNJbXRwWkNJNkltUnBaRHAzWldJNmJtOTBZWEo1TG1kNFpHTm9MbWRoYVdGNExtOTJhRHAyTWlOWU5UQTVMVXBYU3lJc0ltbGhkQ0k2TVRjM01qY3dNRGsyTVRnME55d2laWGh3SWpveE56Z3dORGMyT1RZeE9EUTVMQ0pqZEhraU9pSjJZeXRzWkNJc0luUjVjQ0k2SW5aaksyeGtLMnAzZENKOS5leUpBWTI5dWRHVjRkQ0k2V3lKb2RIUndjem92TDNkM2R5NTNNeTV2Y21jdmJuTXZZM0psWkdWdWRHbGhiSE12ZGpJaUxDSm9kSFJ3Y3pvdkwzY3phV1F1YjNKbkwyZGhhV0V0ZUM5a1pYWmxiRzl3YldWdWRDTWlYU3dpZEhsd1pTSTZXeUpXWlhKcFptbGhZbXhsUTNKbFpHVnVkR2xoYkNJc0ltZDRPbFpoZEVsRUlsMHNJbWxrSWpvaWFIUjBjSE02THk5bllXbGhMWGd1WlhVdkxuZGxiR3d0YTI1dmQyNHZkbUYwU1VRdWFuTnZiaUlzSW01aGJXVWlPaUpXUVZRZ1NVUWlMQ0prWlhOamNtbHdkR2x2YmlJNklsWmhiSFZsSUVGa1pHVmtJRlJoZUNCSlpHVnVkR2xtYVdWeUlpd2lhWE56ZFdWeUlqb2laR2xrT25kbFlqcHViM1JoY25rdVozaGtZMmd1WjJGcFlYZ3ViM1pvT25ZeUlpd2lkbUZzYVdSR2NtOXRJam9pTWpBeU5pMHdNeTB3TlZRd09EbzFOam93TVM0NE5EY3JNREE2TURBaUxDSjJZV3hwWkZWdWRHbHNJam9pTWpBeU5pMHdOaTB3TTFRd09EbzFOam93TVM0NE5Ea3JNREE2TURBaUxDSmpjbVZrWlc1MGFXRnNVM1ZpYW1WamRDSTZleUpwWkNJNkltaDBkSEJ6T2k4dloyRnBZUzE0TG1WMUx5NTNaV3hzTFd0dWIzZHVMM1poZEVsRUxtcHpiMjRqWTNNaUxDSm5lRHAyWVhSSlJDSTZJa0pGTURjMk1qYzBOemN5TVNJc0ltZDRPbU52ZFc1MGNubERiMlJsSWpvaVFrVWlmU3dpWlhacFpHVnVZMlVpT25zaVozZzZaWFpwWkdWdVkyVlBaaUk2SW1kNE9sWmhkRWxFSWl3aVozZzZaWFpwWkdWdVkyVlZVa3dpT2lKb2RIUndPaTh2WldNdVpYVnliM0JoTG1WMUwzUmhlR0YwYVc5dVgyTjFjM1J2YlhNdmRtbGxjeTl6WlhKMmFXTmxjeTlqYUdWamExWmhkRk5sY25acFkyVWlMQ0puZURwbGVHVmpkWFJwYjI1RVlYUmxJam9pTWpBeU5pMHdNeTB3TlZRd09EbzFOam93TVM0NE5EWXJNREE2TURBaWZYMC5tcWFfWWthUXU2RkNRZFB2S3ZMSmhLOXFqdWoyV1RjYWJrc3Q3cU8wd3JtY3RjNXpMMFJaZDNYTS1QT1hrZ1loWDRPQ2xnMUlnejRKUWNOWjQwRmp6MlpoZ0xuMGlLQk4xZlg5bGdCMTl2SHJtTWVsNEVfSlRTTmR1VFZqQTRIcVZUdHdCZlpuOU9tSTBvLVBwX2RXdC1MSWllbHhydUY5RjJfZlM5TkhxeF83dFVDcW82amFqd3RJZE1GM3RVOG9uTUtoNzkweW5oZUZVRGhtamJVa09nTjBrTnRuM1lXd1FFbUxMR3ZxYVcyblhoV3FSMTZadTlqdVNTRUZsTTNqSU9vWHhkQTM1MEdaTnJzRjhvTFhEZUxvNi1vcU5pa2hDclVrMEwwVVRQandRdk0yV05mVUF6QmJnYWpCc0tibkoxWlFyQk1jUkplWk9HYWlhUG95UGQ5V25mUHZjaDBnaTdIMVVuZ1ZBLXN2UW9hb0c4RzRWQ0sxbnc4ZEZvT1pmMDRKaUNla2FXRENUbldIbFFMSzZqaTJSR0o0MDViR05nUEY3aWM4TFVuaG82aUZLcWFWc2NYb2VvSmlJcnd1bUpmdzJ1bXVDaHN6YURXWHZpYTVqbElSZ1d3MGQ2S09SXzhVZzFqUGN2YnBlQXdJVlNMajBNSU5Bb1NDbi1PdWRLWWtHNnlZZGxhYi14UV94eXVSZFJheVVGdjVXWV8tZndtUWJ2UmtPSl9mVDhWQlpMV1dXX2Y3Z2hmbFRLTzhkS1NRQWtZY0c0bjNvMkFZVGRJdmdvMFlqenFmX3JvRHJ0RlphUnZuUFd2SjhuQUZBZk9ETTZRUUpiR2Y4NUczNGREd29MYjRrY3FxTlV6dE15bndlRFM5MWcwR3RKVW5KOWxZY21udTB4NCIsInR5cGUiOiJFbnZlbG9wZWRWZXJpZmlhYmxlQ3JlZGVudGlhbCJ9XSwiaXNzdWVyIjoiZGlkOndlYjpnYWlhLXguZXUiLCJ2YWxpZEZyb20iOiIyMDI2LTAzLTA1VDA5OjU5OjAzLjExMiswMTowMCIsInZhbGlkVW50aWwiOiIyMDI3LTAzLTA1VDA5OjU5OjAzLjExMiswMTowMCJ9.TaxlU1tiVLn6b1A9oHVrQkqIjatRqa_UkMHedgmWu_V7FBM6epOvQh12Vzryf77Eih0IbEPFL6VbrxMPBINObA \ No newline at end of file diff --git a/linkml/harbour-core-credential.yaml b/linkml/harbour-core-credential.yaml index 63e0591..c5b505c 100644 --- a/linkml/harbour-core-credential.yaml +++ b/linkml/harbour-core-credential.yaml @@ -1,4 +1,4 @@ -id: https://w3id.org/reachhaven/harbour/credentials/v1 +id: https://w3id.org/reachhaven/harbour/core/v1 name: harbour description: > Base LinkML schema for Harbour credentials. @@ -13,16 +13,22 @@ description: > # ============================================================================ # [VCDM2] W3C Verifiable Credentials Data Model v2.0 # https://www.w3.org/TR/vc-data-model-2.0/ +# Local: docs/specs/references/vc-data-model-2.0.md # [DID-CORE] W3C Decentralized Identifiers (DIDs) v1.0 # https://www.w3.org/TR/did-core/ +# Local: docs/specs/references/did-core.md # [VC-JOSE-COSE] W3C Securing Verifiable Credentials using JOSE and COSE # https://www.w3.org/TR/vc-jose-cose/ +# Local: docs/specs/references/vc-jose-cose.md # [SD-JWT] RFC 9901: Selective Disclosure for JWTs # https://www.rfc-editor.org/rfc/rfc9901 +# Local: docs/specs/references/sd-jwt-rfc9901.md # [SD-JWT-VC] SD-JWT-based Verifiable Credentials (draft-15) # https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/ +# Local: docs/specs/references/sd-jwt-vc.md # [OID4VP] OpenID for Verifiable Presentations 1.0 (Final) # https://openid.net/specs/openid-4-verifiable-presentations-1_0.html +# Local: docs/specs/references/oid4vp-1.0.md # [SCHEMA-ORG] schema.org vocabulary # https://schema.org/ # [VC-CTX] W3C VC v2 JSON-LD Context @@ -48,9 +54,10 @@ description: > prefixes: linkml: https://w3id.org/linkml/ - harbour: https://w3id.org/reachhaven/harbour/credentials/v1/ + harbour: https://w3id.org/reachhaven/harbour/core/v1/ + harbour.delegate: https://w3id.org/reachhaven/harbour/delegate/v1/ sec: https://w3id.org/security# - sdo: https://schema.org/ + sdo: http://schema.org/ xsd: http://www.w3.org/2001/XMLSchema# rdf: http://www.w3.org/1999/02/22-rdf-syntax-ns# cs: https://www.w3.org/ns/credentials/status# @@ -407,7 +414,7 @@ classes: credential types with typed subjects. class_uri: harbour:VerifiableCredential annotations: - vct: "https://w3id.org/reachhaven/harbour/credentials/v1/VerifiableCredential" + vct: "https://w3id.org/reachhaven/harbour/core/v1/VerifiableCredential" # ========================================== # 3b. PRESENTATION TYPES @@ -446,7 +453,7 @@ classes: specialized presentation types if needed. class_uri: harbour:VerifiablePresentation annotations: - vpt: "https://w3id.org/reachhaven/harbour/credentials/v1/VerifiablePresentation" + vpt: "https://w3id.org/reachhaven/harbour/core/v1/VerifiablePresentation" # ========================================== # 4. EVIDENCE TYPES @@ -466,8 +473,9 @@ classes: Evidence that an authorizing party approved the credential issuance via OID4VP. The embedded VP carries the authorization proof: (1) For LegalPersonCredential: the Trust Anchor presents a VP - containing its self-signed LegalPersonCredential, authorizing - the Signing Service to issue a credential for the organization. + containing its self-signed LinkedCredentialService credential + (service endpoint proof, root of trust), authorizing the + Signing Service to issue a credential for the organization. (2) For NaturalPersonCredential: the organization presents a VP containing its own LegalPersonCredential (SD-JWT with sensitive fields redacted), authorizing the Signing Service to issue a diff --git a/linkml/harbour-core-delegation.yaml b/linkml/harbour-core-delegation.yaml new file mode 100644 index 0000000..e08808c --- /dev/null +++ b/linkml/harbour-core-delegation.yaml @@ -0,0 +1,300 @@ +id: https://w3id.org/reachhaven/harbour/delegate/v1 +name: harbour-delegate +description: > + Delegation transaction types for the Harbour signing flow. + Defines typed transaction data objects used in OID4VP-aligned delegated + signing flows. Each transaction type specifies the action being delegated + (data purchase, blockchain transfer, contract signing, etc.) and the + structure of its action-specific details (txn). + These types are referenced via the harbour.delegate: prefix in the + transaction_data.type field of DelegatedSignatureEvidence (harbour core). + +# ============================================================================ +# SPECIFICATION REFERENCES +# ============================================================================ +# [OID4VP] OpenID for Verifiable Presentations 1.0 (Final) +# https://openid.net/specs/openid-4-verifiable-presentations-1_0.html +# Local: docs/specs/references/oid4vp-1.0.md +# [OID4VP-§5.1] OID4VP §5.1 — transaction_data is a request parameter +# (array of base64url JSON objects). +# [OID4VP-§8.4] OID4VP §8.4 — Wallet MUST process each transaction_data object. +# [OID4VP-§B.3] OID4VP §B.3.3 — KB-JWT carries transaction_data_hashes + _alg. +# [HARBOUR-DEL] Harbour Delegation Challenge Encoding Specification +# Local: docs/specs/delegation-challenge-encoding.md +# [HARBOUR-DEL-§3] §3 — Transaction data structure (type, credential_ids, +# nonce, iat, txn). +# [HARBOUR-DEL-§3.4] §3.4 — Transaction details (txn) by action type. +# [HARBOUR-DEL-§3.6] §3.6 — Computing the hash (canonical JSON, SHA-256). +# +# NAMING CONVENTIONS [HARBOUR-DEL §3.4 "Naming Conventions"] +# ============================================================================ +# OID4VP protocol fields use snake_case exactly as specified: +# transaction_data, credential_ids, transaction_data_hashes_alg +# Harbour action payload (txn) keys use snake_case (profile-defined): +# asset_id, price, currency, marketplace, etc. +# IMPORTANT: txn keys are part of canonicalization and hashing. +# Renaming a key (e.g. asset_id → assetId) changes the canonical JSON +# and therefore changes the challenge/hash binding. +# ============================================================================ + +prefixes: + linkml: https://w3id.org/linkml/ + harbour: https://w3id.org/reachhaven/harbour/core/v1/ + harbour.delegate: https://w3id.org/reachhaven/harbour/delegate/v1/ + +default_prefix: harbour.delegate +default_range: string + +imports: + - linkml:types + +classes: + # ========================================== + # 1. BASE TRANSACTION DATA + # ========================================== + # [OID4VP-§5.1] — transaction_data is an array of base64url JSON objects. + # [HARBOUR-DEL-§3] — structure: type, credential_ids, nonce, iat, txn. + # [HARBOUR-DEL-§3.6] — hash computed over canonical JSON (sorted keys, + # no whitespace) using SHA-256. + + TransactionData: + description: > + Base class for OID4VP-aligned transaction data objects [OID4VP-§5.1]. + Contains common fields required by all transaction types per + [HARBOUR-DEL-§3]: type identifier, credential references, replay + protection (nonce, iat), and optional expiration/description. + Subclasses define the action-specific txn object [HARBOUR-DEL-§3.4]. + class_uri: harbour.delegate:TransactionData + attributes: + # [HARBOUR-DEL-§3.2] — REQUIRED. Format: harbour.delegate: + credential_ids: + description: > + References to DCQL Credential Query id fields that can authorize + this transaction [OID4VP-§5.1]. Array of string identifiers. + slot_uri: harbour.delegate:credential_ids + range: string + multivalued: true + required: true + # [HARBOUR-DEL-§3.2] — REQUIRED. Hash algorithms supported. + transaction_data_hashes_alg: + description: > + Hash algorithms supported for transaction data binding + [OID4VP-§B.3.3]. Default: ["sha-256"]. + slot_uri: harbour.delegate:transaction_data_hashes_alg + range: string + multivalued: true + # [HARBOUR-DEL-§3.2] — REQUIRED. Unique replay protection nonce. + nonce: + description: > + Unique identifier for replay protection [HARBOUR-DEL-§3.2]. + Same nonce appears in the challenge string. + slot_uri: harbour.delegate:nonce + range: string + required: true + # [HARBOUR-DEL-§3.2] — REQUIRED. Issued-at Unix timestamp. + iat: + description: > + Issued-at Unix timestamp (seconds since epoch) + [HARBOUR-DEL-§3.2]. + slot_uri: harbour.delegate:iat + range: integer + required: true + # [HARBOUR-DEL-§3.3] — OPTIONAL. Expiration Unix timestamp. + exp: + description: > + Expiration Unix timestamp (seconds since epoch) + [HARBOUR-DEL-§3.3]. If absent, no expiry. + slot_uri: harbour.delegate:exp + range: integer + # [HARBOUR-DEL-§3.3] — OPTIONAL. Human-readable description. + description: + description: > + Human-readable description for wallet consent display + [HARBOUR-DEL-§3.3]. + slot_uri: harbour.delegate:description + range: string + + # ========================================== + # 2. TRANSACTION TYPES (txn objects) + # ========================================== + # [HARBOUR-DEL-§3.4] — each action type defines its own txn fields. + # These are the typed txn payloads embedded in TransactionData. + + # ------------------------------------------ + # 2a. DATA PURCHASE + # ------------------------------------------ + # [HARBOUR-DEL-§3.4] — harbour.delegate:data.purchase + # txn fields: asset_id, price, currency, marketplace + DataPurchaseTransaction: + description: > + Transaction for purchasing a data asset on a marketplace + [HARBOUR-DEL-§3.4]. The txn object identifies the asset, + price, currency, and marketplace DID. + class_uri: harbour.delegate:data.purchase + attributes: + asset_id: + description: URN or URI identifying the data asset being purchased. + slot_uri: harbour.delegate:asset_id + range: uri + required: true + price: + description: Price amount as a string (to preserve precision). + slot_uri: harbour.delegate:price + range: string + required: true + currency: + description: > + Currency identifier (e.g. "ENVITED", "EUR", "USD"). + slot_uri: harbour.delegate:currency + range: string + required: true + marketplace: + description: DID of the marketplace facilitating the transaction. + slot_uri: harbour.delegate:marketplace + range: uri + required: true + + # ------------------------------------------ + # 2b. BLOCKCHAIN TRANSFER + # ------------------------------------------ + # [HARBOUR-DEL-§3.4] — harbour.delegate:blockchain.transfer + # txn fields: chain, contract, recipient, amount, token + BlockchainTransferTransaction: + description: > + Transaction for transferring tokens or value on a blockchain + [HARBOUR-DEL-§3.4]. Uses CAIP-2 chain identifier format. + class_uri: harbour.delegate:blockchain.transfer + attributes: + chain: + description: > + CAIP-2 chain identifier (e.g. "eip155:42793" for Polygon zkEVM). + slot_uri: harbour.delegate:chain + range: string + required: true + contract: + description: Smart contract address for the transfer. + slot_uri: harbour.delegate:contract + range: string + required: true + recipient: + description: Recipient address on the target chain. + slot_uri: harbour.delegate:recipient + range: string + required: true + amount: + description: > + Transfer amount in smallest unit (wei for EVM chains). + String to preserve precision. + slot_uri: harbour.delegate:amount + range: string + required: true + token: + description: > + Token identifier (contract address or symbol). Optional for + native currency transfers. + slot_uri: harbour.delegate:token + range: string + + # ------------------------------------------ + # 2c. BLOCKCHAIN EXECUTE + # ------------------------------------------ + # [HARBOUR-DEL-§3.4] — harbour.delegate:blockchain.execute + # txn fields: chain, contract, method, params, value + BlockchainExecuteTransaction: + description: > + Transaction for executing a smart contract method on a blockchain + [HARBOUR-DEL-§3.4]. Used for arbitrary contract interactions + beyond simple transfers. + class_uri: harbour.delegate:blockchain.execute + attributes: + chain: + description: > + CAIP-2 chain identifier (e.g. "eip155:42793" for Polygon zkEVM). + slot_uri: harbour.delegate:chain_exec + range: string + required: true + contract: + description: Smart contract address to interact with. + slot_uri: harbour.delegate:contract_exec + range: string + required: true + method: + description: > + Smart contract method name or function selector to invoke. + slot_uri: harbour.delegate:method + range: string + required: true + params: + description: > + Method parameters as a JSON-encoded string (preserves + arbitrary typed arguments). + slot_uri: harbour.delegate:params + range: string + value: + description: > + Native currency value to send with the transaction (in wei). + String to preserve precision. + slot_uri: harbour.delegate:value + range: string + + # ------------------------------------------ + # 2d. CONTRACT SIGN + # ------------------------------------------ + # [HARBOUR-DEL-§3.4] — harbour.delegate:contract.sign + # txn fields: document_hash, document_uri, parties + ContractSignTransaction: + description: > + Transaction for digitally signing a legal or business document + [HARBOUR-DEL-§3.4]. References the document by hash and URI, + and lists all signing parties. + class_uri: harbour.delegate:contract.sign + attributes: + document_hash: + description: > + Content hash of the document (format: "sha256:"). + Used for integrity verification. + slot_uri: harbour.delegate:document_hash + range: string + required: true + document_uri: + description: URI where the document can be retrieved. + slot_uri: harbour.delegate:document_uri + range: uri + parties: + description: > + DIDs of all parties involved in the signing ceremony. + slot_uri: harbour.delegate:parties + range: uri + multivalued: true + required: true + + # ------------------------------------------ + # 2e. CREDENTIAL ISSUE + # ------------------------------------------ + # [HARBOUR-DEL-§3.4] — harbour.delegate:credential.issue + # txn fields: credential_type, subject, claims + CredentialIssueTransaction: + description: > + Transaction for delegating credential issuance to a signing service + [HARBOUR-DEL-§3.4]. The user authorizes Haven to issue a credential + of the specified type to the specified subject. + class_uri: harbour.delegate:credential.issue + attributes: + credential_type: + description: > + Type URI of the credential to be issued (e.g. + "harbour.gx:NaturalPersonCredential"). + slot_uri: harbour.delegate:credential_type + range: string + required: true + subject: + description: DID of the credential subject. + slot_uri: harbour.delegate:subject + range: uri + required: true + claims: + description: > + JSON-encoded claims to include in the credential. + Preserved as string to support arbitrary claim structures. + slot_uri: harbour.delegate:claims + range: string diff --git a/linkml/harbour-gx-credential.yaml b/linkml/harbour-gx-credential.yaml index 97f6562..e099ce8 100644 --- a/linkml/harbour-gx-credential.yaml +++ b/linkml/harbour-gx-credential.yaml @@ -1,26 +1,59 @@ -id: https://w3id.org/reachhaven/harbour/gaiax-domain/v1 +id: https://w3id.org/reachhaven/harbour/gx/v1 name: harbour-gx-credential description: > Gaia-X domain layer for Harbour credentials. - Defines participant types via inheritance from gx: base classes. + Defines participant types via inheritance from gx: base classes and + compliance-verified credential types for the Gaia-X flow. harbour:LegalPerson extends gx:LegalPerson (inherits registrationNumber, - legalAddress, headquartersAddress). harbour:NaturalPerson extends - gx:Participant directly — Gaia-X has no NaturalPerson, so Harbour - defines one as a sibling of gx:LegalPerson in the gx type hierarchy. + legalAddress, headquartersAddress) and adds SHACL-enforced compliance + slots that require references to all three Gaia-X participant VCs. + harbour:NaturalPerson extends gx:Participant directly — Gaia-X has + no NaturalPerson, so Harbour defines one as a sibling of gx:LegalPerson. + Key design: LegalPersonCredential IS the compliance credential — + holding a valid one means Haven verified the three underlying Gaia-X + VCs (LegalPerson, VatID, Issuer/T&C). The input VCs are plain Gaia-X + (no harbour envelope); harbour only wraps the compliance output. # ============================================================================ # SPECIFICATION REFERENCES # ============================================================================ # [VCDM2] W3C Verifiable Credentials Data Model v2.0 # https://www.w3.org/TR/vc-data-model-2.0/ +# Local: docs/specs/references/vc-data-model-2.0.md # [SD-JWT-VC] SD-JWT-based Verifiable Credentials (draft-15) # https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/ +# Local: docs/specs/references/sd-jwt-vc.md # [GX-AD] Gaia-X Architecture Document 25.11 # https://docs.gaia-x.eu/technical-committee/architecture-document/25.11/ -# [GX-SHACL] Gaia-X SHACL Shapes (local: OMB artifacts/gx/gx.shacl.ttl) +# Local: docs/specs/references/gx-architecture-document-25.11.md +# [GX-CD] Gaia-X Compliance Document 25.10 (Loire) +# https://docs.gaia-x.eu/policy-rules-committee/compliance-document/25.10/ +# Local: docs/specs/references/gx-compliance-document-25.10.md +# [GX-CD-PA] Gaia-X Compliance Document 25.10 — §5 Participant Criteria +# https://docs.gaia-x.eu/policy-rules-committee/compliance-document/25.10/criteria_participant/ +# Local: docs/specs/references/gx-compliance-document-25.10.md §5 +# [GX-CD-TA] Gaia-X Compliance Document 25.10 — §8 Trust Anchors +# https://docs.gaia-x.eu/policy-rules-committee/compliance-document/25.10/Gaia-X_Trust_Anchors/ +# Local: docs/specs/references/gx-compliance-document-25.10.md §8 +# [GX-CD-PROC] Gaia-X Compliance Document 25.10 — §12 Process +# https://docs.gaia-x.eu/policy-rules-committee/compliance-document/25.10/Process/ +# Local: docs/specs/references/gx-compliance-document-25.10.md §12 +# [GX-CD-LABEL] Gaia-X Compliance Document 25.10 — §10 Label Format +# https://docs.gaia-x.eu/policy-rules-committee/compliance-document/latest/annex_label_format/ +# Local: docs/specs/references/gx-compliance-document-25.10.md §10 +# [GX-SHACL] Gaia-X SHACL Shapes # Namespace: https://w3id.org/gaia-x/development# +# Local: submodules/ontology-management-base/artifacts/gx/gx.shacl.ttl +# [GX-ONT-LP] Gaia-X Ontology — LegalPerson +# https://docs.gaia-x.eu/ontology/development/classes/LegalPerson/ +# Local: docs/specs/references/gx-compliance-document-25.10.md §5 (ontology links) +# [GX-ONT-PA] Gaia-X Ontology — Participant (abstract) +# https://docs.gaia-x.eu/ontology/development/classes/Participant/ +# Local: docs/specs/references/gx-compliance-document-25.10.md §5 (ontology links) # [SCHEMA-ORG] schema.org vocabulary # https://schema.org/ +# [SRI] W3C Subresource Integrity +# https://www.w3.org/TR/SRI/ # # DESIGN DECISIONS # ============================================================================ @@ -28,17 +61,26 @@ description: > # classes directly via is_a, placing them in the gx type hierarchy: # # gx:Participant (abstract) -# ├─ gx:LegalPerson -# │ └─ harbour:LegalPerson (is_a: LegalPerson) +# ├─ gx:LegalPerson (entity data — used in plain gx input VCs) # └─ harbour:NaturalPerson (is_a: Participant) # -# harbour:LegalPerson inherits registrationNumber, legalAddress, -# headquartersAddress from gx:LegalPerson. -# harbour:NaturalPerson inherits name, description from gx:Participant -# and adds person-specific attributes (givenName, familyName, address). +# harbour:LegalPerson — pure compliance attestation type +# (no gx inheritance — entity data in referenced gx:LegalPerson VC) # -# Gaia-X has no NaturalPerson — Harbour defines one as a direct subclass -# of gx:Participant, parallel to gx:LegalPerson. +# HarbourCredential (abstract) +# └─ ComplianceCredential (abstract — marker for compliance-verified) +# └─ LegalPersonCredential (= compliance credential for orgs) +# +# LegalPersonCredential IS the compliance credential: holding a valid one +# means Haven has verified all three Gaia-X VCs (LegalPerson, VatID, +# Issuer/T&C). The input VCs are plain Gaia-X (VerifiableCredential +# only, no harbour envelope type). +# +# harbour:LegalPerson adds compliance enforcement slots +# (compliantLegalPersonVC, compliantRegistrationVC, compliantTermsVC) +# with SHACL sh:minCount 1 — guaranteeing all three Gaia-X VCs are +# present. This is machine-readable enforcement that the Gaia-X Loire +# specification is missing. # # Harbour SHACL is generated with exclude_imports=True to avoid # duplicating gx shapes. gx shapes are validated separately. @@ -46,14 +88,14 @@ description: > prefixes: linkml: https://w3id.org/linkml/ - harbour: https://w3id.org/reachhaven/harbour/credentials/v1/ - harbour_gx: https://w3id.org/reachhaven/harbour/gaiax-domain/v1/ - sdo: https://schema.org/ + harbour: https://w3id.org/reachhaven/harbour/core/v1/ + harbour.gx: https://w3id.org/reachhaven/harbour/gx/v1/ + sdo: http://schema.org/ xsd: http://www.w3.org/2001/XMLSchema# cred: https://www.w3.org/2018/credentials# gx: https://w3id.org/gaia-x/development# -default_prefix: harbour_gx +default_prefix: harbour.gx default_range: string imports: @@ -64,18 +106,20 @@ imports: # ========================================== # Composition Slot (used by simpulseid layer) # ========================================== -# The gxParticipant slot allows simpulseid credential subjects to +# The participant slot allows simpulseid credential subjects to # reference a harbour participant (LegalPerson or NaturalPerson) # as a nested node. Typed as gx:Participant — the abstract base # for all participant types in the gx hierarchy. slots: - gxParticipant: + # [GX-ONT-PA] — gx:Participant is the abstract base for all + # participant types in the Gaia-X hierarchy. + participant: description: > Reference to a Gaia-X participant node (harbour:LegalPerson or harbour:NaturalPerson). Used by downstream credential layers (e.g. simpulseid) to embed participant data in credential subjects. - slot_uri: harbour_gx:gxParticipant + slot_uri: harbour.gx:participant range: Participant required: false @@ -84,77 +128,288 @@ classes: # ========================================== # 1. CREDENTIAL TYPES # ========================================== - # Concrete credential types for harbour use case stories. - # Defined in the domain layer because the credentialSubject types - # (LegalPerson, NaturalPerson) carry domain-specific composition - # slots (gxParticipant) that must be present in the SHACL closed shapes. + # Credential types for the harbour Gaia-X compliance flow. # [VCDM2] §4 — each credential MUST have @context, type, issuer, # credentialSubject. Harbour profile additionally requires validFrom # and credentialStatus (inherited from HarbourCredential). + # + # Architecture: LegalPersonCredential IS the compliance credential. + # Holding a valid one means Haven has verified all three required + # Gaia-X VCs (LegalPerson, VatID, Issuer/T&C). The input VCs are + # plain Gaia-X (VerifiableCredential only, no harbour envelope type). + # The SHACL shape for harbour.gx:LegalPerson enforces the presence + # of all three VC references — machine-readable enforcement that + # the Gaia-X Loire specification is missing. - LegalPersonCredential: + # ------------------------------------------ + # 1a. ABSTRACT COMPLIANCE BASE + # ------------------------------------------ + # [GX-CD-PA] §5.1 Criterion PA1.1 — participant must provide + # gx:Participant + gx:LegalPerson + gx:Issuer (T&C) information. + # [GX-CD-PROC] §12 — compliance VP verified by GXDCH, compliance + # credential issued on success. + # ComplianceCredential is an abstract marker: subclasses represent + # credentials where Haven has verified underlying Gaia-X VCs. + + ComplianceCredential: is_a: HarbourCredential + abstract: true + description: > + Abstract base class for compliance-verified credential types. + Subclasses represent credentials where Haven (compliance service) + has verified the underlying Gaia-X participant VCs per + [GX-CD-PA] §5.1 Criterion PA1.1. A valid ComplianceCredential + proves Gaia-X compliance — the credentialSubject type enforces + (via SHACL) that all required Gaia-X VC references are present. + class_uri: harbour.gx:ComplianceCredential + + # ------------------------------------------ + # 1b. CONCRETE CREDENTIAL TYPES + # ------------------------------------------ + + # [GX-CD-PA] §5.1 Criterion PA1.1 — three VCs required for compliance. + # [GX-CD-PROC] §12 — compliance VP submitted to GXDCH, credential + # issued on success. + # [GX-CD-LABEL] §10 — label levels (SC, L1, L2, L3) attach to this + # credential type. + LegalPersonCredential: + is_a: ComplianceCredential description: > - Credential attesting to a harbour:LegalPerson (organization) identity. - The credentialSubject is a harbour:LegalPerson which extends - gx:LegalPerson — inheriting registrationNumber, legalAddress, - headquartersAddress directly. - class_uri: harbour_gx:LegalPersonCredential + Credential attesting to a harbour.gx:LegalPerson (organization) identity + AND its Gaia-X compliance status. Issued by Haven (compliance service) + after verifying the three required Gaia-X VCs per [GX-CD-PA] §5.1: + (1) gx:LegalPerson — self-signed entity identity [GX-ONT-LP] + (2) gx:VatID — notary-verified registration number [GX-CD-TA] §8 + (3) gx:Issuer — self-signed T&C acceptance [GX-CD-PA] §5.1 + The credentialSubject is a harbour.gx:LegalPerson which carries + compliance enforcement slots (compliantLegalPersonVC, + compliantRegistrationVC, compliantTermsVC) — each enforced by + SHACL sh:minCount 1. + Holding a valid LegalPersonCredential = Gaia-X compliant. + See [GX-CD-PROC] §12 for the issuance process. + class_uri: harbour.gx:LegalPersonCredential annotations: # [SD-JWT-VC] §3.2.2.1 — vct MUST be a case-sensitive StringOrURI # identifying the credential type. - vct: "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/LegalPersonCredential" + vct: "https://w3id.org/reachhaven/harbour/gx/v1/LegalPersonCredential" slot_usage: validFrom: required: true evidence: required: true + # [GX-ONT-PA] — gx:Participant is the abstract base; Gaia-X defines + # no NaturalPerson type, so this credential type is a Harbour extension. + # [VCDM2] §4 — standard VC envelope requirements apply. NaturalPersonCredential: is_a: HarbourCredential description: > - Credential attesting to a harbour:NaturalPerson (individual) identity. - The credentialSubject is a harbour:NaturalPerson which extends - gx:Participant directly — inheriting name/description and adding - person-specific attributes (givenName, familyName, address, email). - class_uri: harbour_gx:NaturalPersonCredential + Credential attesting to a harbour.gx:NaturalPerson (individual) identity. + The credentialSubject is a harbour.gx:NaturalPerson which extends + gx:Participant [GX-ONT-PA] directly — Gaia-X defines no NaturalPerson, + so Harbour creates one as a sibling of gx:LegalPerson [GX-ONT-LP]. + Inherits name/description from gx:Participant and adds person-specific + attributes (givenName, familyName, address, email) from [SCHEMA-ORG]. + class_uri: harbour.gx:NaturalPersonCredential annotations: # [SD-JWT-VC] §3.2.2.1 — vct MUST be a case-sensitive StringOrURI. - vct: "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/NaturalPersonCredential" + vct: "https://w3id.org/reachhaven/harbour/gx/v1/NaturalPersonCredential" slot_usage: validFrom: required: true evidence: required: true + # ========================================== - # 2. PARTICIPANT TYPES + # 2. CREDENTIAL SUBJECT TYPES # ========================================== - # Harbour participant types extend gx: base classes directly. - # harbour:LegalPerson is_a gx:LegalPerson — inherits all gx compliance - # properties (registrationNumber, legalAddress, headquartersAddress). - # harbour:NaturalPerson is_a gx:Participant — Gaia-X has no NaturalPerson, - # so Harbour creates one as a sibling of gx:LegalPerson. + # harbour.gx:LegalPerson — pure compliance attestation type. Does NOT + # extend gx:LegalPerson [GX-ONT-LP] (to avoid gx:LegalPersonShape + # sh:closed true violations [GX-SHACL]). Entity data lives in the + # referenced plain gx:LegalPerson input VC. This type only carries + # compliance enforcement slots (VC refs + metadata per [GX-CD-LABEL] §10). + # harbour.gx:NaturalPerson — extends gx:Participant [GX-ONT-PA]. Gaia-X has + # no NaturalPerson, so Harbour creates one as a sibling of gx:LegalPerson. + + # ------------------------------------------ + # 2a. LEGAL PERSON (compliance attestation) + # ------------------------------------------ + # [GX-CD-PA] §5.1 Criterion PA1.1 — three VCs required for compliance. + # [GX-SHACL] gx:LegalPersonShape uses sh:closed true; harbour.gx:LegalPerson + # is a separate type to avoid violating gx closed shapes. + # [GX-CD-LABEL] §10 — label metadata (labelLevel, engineVersion, etc.) + # attaches to this subject type. HarbourLegalPerson: - is_a: LegalPerson description: > - A legal person (organization) in the harbour ecosystem. - Extends gx:LegalPerson — inherits registrationNumber, legalAddress, - headquartersAddress, name, description from the Gaia-X type hierarchy. - class_uri: harbour_gx:LegalPerson + Compliance attestation for a legal person (organization) in the + harbour ecosystem. A pure compliance type — does NOT contain entity + data (name, addresses, registrationNumber). Entity data lives in + the referenced gx:LegalPerson input VC [GX-ONT-LP]. + SHACL-enforced compliance slots require references to all three + Gaia-X participant VCs per [GX-CD-PA] §5.1 PA1.1 (LegalPerson, + VatID, Issuer/T&C) with integrity hashes [SRI]. This is the key + harbour value-add over raw Gaia-X: machine-readable enforcement + that the Loire specification is missing. + class_uri: harbour.gx:LegalPerson + attributes: + # [GX-ONT-LP] — references the self-signed gx:LegalPerson VC + # containing registrationNumber, legalAddress, headquartersAddress. + compliantLegalPersonVC: + description: > + Reference to the verified gx:LegalPerson self-description + credential with integrity hash [SRI]. The referenced VC + contains entity data per [GX-ONT-LP]: registrationNumber (≥1), + legalAddress (=1), headquartersAddress (=1). + slot_uri: harbour.gx:compliantLegalPersonVC + range: CompliantCredentialReference + required: true + inlined: true + # [GX-CD-TA] §8.3 — registration number VC signed by accredited + # Gaia-X Notary after verification against Trusted Data Sources + # (VIES for vatID, GLEIF for leiCode, OpenCorporate for local). + compliantRegistrationVC: + description: > + Reference to the verified gx:VatID (or other registration + number) credential with integrity hash [SRI]. The referenced + VC is notary-signed per [GX-CD-TA] §8.3, verified against + accredited Trusted Data Sources (VIES, GLEIF, etc.). + slot_uri: harbour.gx:compliantRegistrationVC + range: CompliantCredentialReference + required: true + inlined: true + # [GX-CD-PA] §5.1 — T&C acceptance is mandatory for PA1.1. + # The referenced VC contains gx:gaiaxTermsAndConditions (SHA-256 + # hash of the T&C text). + compliantTermsVC: + description: > + Reference to the verified gx:Issuer (T&C acceptance) + credential with integrity hash [SRI]. The referenced VC is + self-signed and contains gx:gaiaxTermsAndConditions per + [GX-CD-PA] §5.1 — a SHA-256 hash of the Gaia-X T&C text. + slot_uri: harbour.gx:compliantTermsVC + range: CompliantCredentialReference + required: true + inlined: true + # [GX-CD] §3.1 — label levels: SC (Standard Compliance), L1, L2, L3. + # [GX-CD-LABEL] §10 — label is a machine-readable VC containing + # conformity assessment scheme level. + labelLevel: + description: > + Gaia-X conformity label level per [GX-CD] §3.1. + Valid values: SC (Standard Compliance), L1, L2, L3. + See [GX-CD-LABEL] §10 for machine-readable label format. + slot_uri: harbour.gx:labelLevel + range: string + required: true + # [GX-CD-LABEL] §10 — Compliance Service version (software version + # of the GXDCH instance that performed validation). + engineVersion: + description: > + Version of the Gaia-X compliance engine (GXDCH instance) + that performed the validation. See [GX-CD-LABEL] §10. + slot_uri: harbour.gx:engineVersion + range: string + required: true + # [GX-CD-LABEL] §10 — Reference to the assessment scheme version + # (e.g. "CD25.10" for Loire). + rulesVersion: + description: > + Version of the Gaia-X compliance document from which the + validated criteria originate (e.g. "CD25.10" for Loire). + See [GX-CD-LABEL] §10. + slot_uri: harbour.gx:rulesVersion + range: string + required: true + # [GX-CD-PA] §5.1 — list of criteria validated (e.g. PA1.1). + validatedCriteria: + description: > + List of Gaia-X compliance criteria URIs validated by the + compliance engine. Typically includes PA1.1 per [GX-CD-PA] + §5.1 for participant compliance. + slot_uri: harbour.gx:validatedCriteria + range: string + multivalued: true + required: true + + # ------------------------------------------ + # 2b. COMPLIANT CREDENTIAL REFERENCE + # ------------------------------------------ + # [GX-CD-LABEL] §10 — label format includes references to assessed + # credentials. CompliantCredentialReference mirrors the + # gx:CompliantCredential pattern from the compliance spec. + # [SRI] — Subresource Integrity hash ensures referenced credential + # integrity without requiring re-download. + + CompliantCredentialReference: + description: > + Reference to a credential validated by the compliance engine. + Includes the credential type and a subresource integrity hash + [SRI] to ensure the referenced credential has not been modified. + Mirrors the gx:CompliantCredential pattern from [GX-CD-LABEL] §10. + Optionally embeds the full credential for self-contained + verification (no external resolution needed). + class_uri: harbour.gx:CompliantCredentialReference + attributes: + # [GX-CD-LABEL] §10 — identifies which gx type was assessed + # (e.g. gx:LegalPerson, gx:VatID, gx:Issuer). + credentialType: + description: > + Type of the compliant credential per [GX-CD-LABEL] §10, + e.g. "gx:LegalPerson", "gx:VatID", "gx:Issuer". + slot_uri: harbour.gx:credentialType + range: string + required: true + # [SRI] W3C Subresource Integrity — format: "sha256-{hex_digest}". + # https://www.w3.org/TR/SRI/ + digestSRI: + description: > + Subresource Integrity [SRI] hash of the verifiable credential. + Format: "sha256-{hex_digest}". + See https://www.w3.org/TR/SRI/ + slot_uri: harbour.gx:digestSRI + range: string + required: true + embeddedCredential: + description: > + Optional embedded verifiable credential. When present, the + full credential is included inline for self-contained + verification. When absent, the credential must be resolved + externally (e.g. via the participant VP or a credential + registry). The digestSRI [SRI] still serves as integrity + proof in both cases. + Named 'embeddedCredential' (not 'verifiableCredential') to + avoid JSON-LD term collision with cred:verifiableCredential + from the W3C VC context [VCDM2]. + slot_uri: harbour.gx:embeddedCredential + range: string + required: false + inlined: true + + # ------------------------------------------ + # 2c. NATURAL PERSON (harbour extension) + # ------------------------------------------ + # [GX-ONT-PA] — gx:Participant is the abstract base; Gaia-X defines + # no NaturalPerson type, so Harbour creates one as a sibling of + # gx:LegalPerson [GX-ONT-LP] under gx:Participant. + # [SCHEMA-ORG] — person-specific attributes (givenName, familyName, + # memberOf, email) use http://schema.org/ vocabulary. HarbourNaturalPerson: is_a: Participant description: > A natural person (individual) in the harbour ecosystem. - Extends gx:Participant directly — Gaia-X defines no NaturalPerson, - so Harbour creates one as a sibling of gx:LegalPerson. - Inherits name, description from gx:Participant. Adds person-specific - attributes (givenName, familyName, address, email, memberOf). - class_uri: harbour_gx:NaturalPerson + Extends gx:Participant [GX-ONT-PA] directly — Gaia-X defines no + NaturalPerson type, so Harbour creates one as a sibling of + gx:LegalPerson [GX-ONT-LP]. Inherits name, description from + gx:Participant. Adds person-specific attributes from [SCHEMA-ORG] + (givenName, familyName, email, memberOf) and gx:address. + class_uri: harbour.gx:NaturalPerson slots: + # [GX-ONT-PA] — gx:address (vcard:Address) from gx:Participant. - address + # [SCHEMA-ORG] — http://schema.org/email - email slot_usage: email: @@ -162,17 +417,17 @@ classes: address: slot_uri: gx:address attributes: - # [SCHEMA-ORG] — https://schema.org/givenName + # [SCHEMA-ORG] — http://schema.org/givenName givenName: description: First name / given name of the natural person. slot_uri: sdo:givenName range: string - # [SCHEMA-ORG] — https://schema.org/familyName + # [SCHEMA-ORG] — http://schema.org/familyName familyName: description: Last name / family name of the natural person. slot_uri: sdo:familyName range: string - # [SCHEMA-ORG] — https://schema.org/memberOf + # [SCHEMA-ORG] — http://schema.org/memberOf memberOf: description: Organization (LegalPerson) the natural person belongs to. slot_uri: sdo:memberOf diff --git a/src/python/credentials/claim_mapping.py b/src/python/credentials/claim_mapping.py index 9d5a9c5..2c8f216 100644 --- a/src/python/credentials/claim_mapping.py +++ b/src/python/credentials/claim_mapping.py @@ -23,10 +23,10 @@ # --------------------------------------------------------------------------- # Harbour namespace -HARBOUR_NS = "https://w3id.org/reachhaven/harbour/credentials/v1/" +HARBOUR_NS = "https://w3id.org/reachhaven/harbour/core/v1/" # Harbour Gaia-X domain namespace -HARBOUR_GX_NS = "https://w3id.org/reachhaven/harbour/gaiax-domain/v1/" +HARBOUR_GX_NS = "https://w3id.org/reachhaven/harbour/gx/v1/" # Gaia-X namespace GAIAX_NS = "https://w3id.org/gaia-x/development#" @@ -38,16 +38,14 @@ HARBOUR_LEGAL_PERSON_MAPPING = { "vct": f"{HARBOUR_GX_NS}LegalPersonCredential", "claims": { - "credentialSubject.name": "legalName", - "credentialSubject.registrationNumber": "registrationNumber", - "credentialSubject.headquartersAddress": "headquartersAddress", - "credentialSubject.legalAddress": "legalAddress", + r"credentialSubject.harbour\.gx:labelLevel": "labelLevel", + r"credentialSubject.harbour\.gx:engineVersion": "engineVersion", + r"credentialSubject.harbour\.gx:rulesVersion": "rulesVersion", }, - "always_disclosed": ["iss", "vct", "iat", "exp", "legalName"], + "always_disclosed": ["iss", "vct", "iat", "exp", "labelLevel"], "selectively_disclosed": [ - "registrationNumber", - "headquartersAddress", - "legalAddress", + "engineVersion", + "rulesVersion", ], } @@ -64,17 +62,17 @@ } # --------------------------------------------------------------------------- -# Gaia-X domain mappings (with gxParticipant inner node) -# Used when the credential wraps gx data inside a gxParticipant nested object. +# Gaia-X domain mappings (with participant inner node) +# Used when the credential wraps gx data inside a participant nested object. # --------------------------------------------------------------------------- GAIAX_LEGAL_PERSON_MAPPING = { "vct": f"{HARBOUR_GX_NS}LegalPersonCredential", "claims": { - "credentialSubject.gxParticipant.name": "legalName", - "credentialSubject.gxParticipant.gx:registrationNumber": "registrationNumber", - "credentialSubject.gxParticipant.gx:headquartersAddress": "headquartersAddress", - "credentialSubject.gxParticipant.gx:legalAddress": "legalAddress", + "credentialSubject.participant.name": "legalName", + "credentialSubject.participant.gx:registrationNumber": "registrationNumber", + "credentialSubject.participant.gx:headquartersAddress": "headquartersAddress", + "credentialSubject.participant.gx:legalAddress": "legalAddress", }, "always_disclosed": ["iss", "vct", "iat", "exp", "legalName"], "selectively_disclosed": [ @@ -87,9 +85,9 @@ GAIAX_NATURAL_PERSON_MAPPING = { "vct": f"{HARBOUR_GX_NS}NaturalPersonCredential", "claims": { - "credentialSubject.gxParticipant.givenName": "givenName", - "credentialSubject.gxParticipant.familyName": "familyName", - "credentialSubject.gxParticipant.email": "email", + "credentialSubject.participant.givenName": "givenName", + "credentialSubject.participant.familyName": "familyName", + "credentialSubject.participant.email": "email", "credentialSubject.memberOf": "memberOf", }, "always_disclosed": ["iss", "vct", "iat", "exp"], @@ -107,14 +105,14 @@ # Base harbour mappings (skeleton credentials) MAPPINGS: dict[str, dict] = { - "harbour_gx:LegalPersonCredential": HARBOUR_LEGAL_PERSON_MAPPING, - "harbour_gx:NaturalPersonCredential": HARBOUR_NATURAL_PERSON_MAPPING, + "harbour.gx:LegalPersonCredential": HARBOUR_LEGAL_PERSON_MAPPING, + "harbour.gx:NaturalPersonCredential": HARBOUR_NATURAL_PERSON_MAPPING, } -# Gaia-X domain mappings (extended with gxParticipant) +# Gaia-X domain mappings (extended with participant) GAIAX_MAPPINGS: dict[str, dict] = { - "harbour_gx:LegalPersonCredential": GAIAX_LEGAL_PERSON_MAPPING, - "harbour_gx:NaturalPersonCredential": GAIAX_NATURAL_PERSON_MAPPING, + "harbour.gx:LegalPersonCredential": GAIAX_LEGAL_PERSON_MAPPING, + "harbour.gx:NaturalPersonCredential": GAIAX_NATURAL_PERSON_MAPPING, } @@ -256,7 +254,7 @@ def get_mapping_for_vc(vc: dict) -> dict | None: elif isinstance(at_type, list): vc_types = vc_types + at_type - # Use primary registry — GAIAX_MAPPINGS is reserved for gxParticipant-nested + # Use primary registry — GAIAX_MAPPINGS is reserved for participant-nested # patterns (not yet used in current examples). for vc_type, mapping in MAPPINGS.items(): if vc_type in vc_types: @@ -295,8 +293,13 @@ def create_mapping( def _get_nested(obj: dict, path: str) -> Any: - """Get a nested value by dot-separated path.""" - parts = path.split(".") + """Get a nested value by dot-separated path. + + Dots inside keys can be escaped with a backslash (``\\.``). + Unescaped dots are path separators; escaped dots are literal. + Also falls back to greedy key matching for existing keys with dots. + """ + parts = _split_path(path) current: Any = obj for part in parts: if isinstance(current, dict): @@ -307,8 +310,11 @@ def _get_nested(obj: dict, path: str) -> Any: def _set_nested(obj: dict, path: str, value: Any) -> None: - """Set a nested value by dot-separated path.""" - parts = path.split(".") + """Set a nested value by dot-separated path. + + Dots inside keys can be escaped with a backslash (``\\.``). + """ + parts = _split_path(path) current = obj for part in parts[:-1]: if part not in current: @@ -317,6 +323,17 @@ def _set_nested(obj: dict, path: str, value: Any) -> None: current[parts[-1]] = value +def _split_path(path: str) -> list[str]: + r"""Split a dot-delimited path, respecting escaped dots (``\.``). + + ``credentialSubject.harbour\.gx:labelLevel`` → ``["credentialSubject", "harbour.gx:labelLevel"]`` + """ + import re + + parts = re.split(r"(?) + type: Transaction data type identifier (harbour.delegate:) credential_ids: References to DCQL Credential Query id fields nonce: Unique identifier for replay protection iat: Issued-at Unix timestamp (seconds since epoch) @@ -92,7 +92,7 @@ class TransactionData: def action(self) -> str: """Extract the action from the type field. - E.g., "harbour_delegate:data.purchase" -> "data.purchase" + E.g., "harbour.delegate:data.purchase" -> "data.purchase" """ if ":" in self.type: return self.type.split(":", 1)[1] diff --git a/src/python/harbour/generate_artifacts.py b/src/python/harbour/generate_artifacts.py index 89e0c3b..cb0bb42 100644 --- a/src/python/harbour/generate_artifacts.py +++ b/src/python/harbour/generate_artifacts.py @@ -26,7 +26,20 @@ LINKML_DIR = REPO_ROOT / "linkml" ARTIFACTS_DIR = REPO_ROOT / "artifacts" -DOMAINS = ["harbour-core-credential", "harbour-gx-credential"] +DOMAINS = [ + "harbour-core-credential", + "harbour-gx-credential", + "harbour-core-delegation", +] + +# Domains where SHACL shapes should NOT be generated. +# harbour-core-delegation defines transaction data types used inside +# DelegatedSignatureEvidence.transaction_data — an opaque canonical JSON +# payload for OID4VP hash binding [OID4VP §5.1]. SHACL validation of its +# contents is inappropriate because the data is validated by SHA-256 hash +# binding (not RDF graph shape), and JSON-LD expansion would interfere +# with the canonical JSON used for hashing. +SHACL_SKIP_DOMAINS = {"harbour-core-delegation"} SH = Namespace("http://www.w3.org/ns/shacl#") XSD = Namespace("http://www.w3.org/2001/XMLSchema#") @@ -130,12 +143,13 @@ def main() -> None: (out_dir / f"{domain}.owl.ttl").write_text(owl_text, encoding="utf-8") - shacl_gen = HarbourShaclGenerator( - schema, importmap=importmap, base_dir=base_dir - ) - (out_dir / f"{domain}.shacl.ttl").write_text( - shacl_gen.serialize(), encoding="utf-8" - ) + if domain not in SHACL_SKIP_DOMAINS: + shacl_gen = HarbourShaclGenerator( + schema, importmap=importmap, base_dir=base_dir + ) + (out_dir / f"{domain}.shacl.ttl").write_text( + shacl_gen.serialize(), encoding="utf-8" + ) ctx_gen = HarbourContextGenerator( schema, mergeimports=False, importmap=importmap, base_dir=base_dir diff --git a/src/python/harbour/verifier.py b/src/python/harbour/verifier.py index 6525f6a..aae45b9 100644 --- a/src/python/harbour/verifier.py +++ b/src/python/harbour/verifier.py @@ -89,9 +89,10 @@ def _verify_jose(token: str, public_key: PublicKeyType, expected_typ: str) -> di key = _import_public_key(public_key) alg = _alg_for_key(public_key) - # Use a larger header limit to accommodate x5c certificate chains + # Use larger limits to accommodate x5c certificate chains and embedded credentials registry = jws.JWSRegistry(algorithms=[alg]) registry.max_header_length = 8192 + registry.max_payload_length = 65536 try: result = jws.deserialize_compact( diff --git a/src/typescript/harbour/delegation.ts b/src/typescript/harbour/delegation.ts index 87b2ceb..f81be63 100644 --- a/src/typescript/harbour/delegation.ts +++ b/src/typescript/harbour/delegation.ts @@ -14,7 +14,7 @@ export const ACTION_TYPE = "HARBOUR_DELEGATE"; /** Type prefix for transaction data. */ -export const TYPE_PREFIX = "harbour_delegate"; +export const TYPE_PREFIX = "harbour.delegate"; /** Human-friendly labels for action types. */ export const ACTION_LABELS: Record = { @@ -43,7 +43,7 @@ export class ChallengeError extends Error { /** OID4VP-aligned transaction data object for delegated signing. */ export interface TransactionData { - /** Transaction data type identifier (harbour_delegate:). */ + /** Transaction data type identifier (harbour.delegate:). */ type: string; /** References to DCQL Credential Query id fields. */ credential_ids: string[]; @@ -64,7 +64,7 @@ export interface TransactionData { /** * Extract the action from the type field. * - * E.g., "harbour_delegate:data.purchase" -> "data.purchase" + * E.g., "harbour.delegate:data.purchase" -> "data.purchase" */ export function getAction(td: TransactionData): string { const idx = td.type.indexOf(":"); diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index 15bf33a..4327579 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit 15bf33ac6050f5cb48144be42cff513d38746458 +Subproject commit 43275794dac66649fc3e6b9c154bef74a957d234 diff --git a/tests/fixtures/canonicalization-vectors.json b/tests/fixtures/canonicalization-vectors.json index 193bd4b..42c5b6c 100644 --- a/tests/fixtures/canonicalization-vectors.json +++ b/tests/fixtures/canonicalization-vectors.json @@ -4,7 +4,7 @@ { "name": "data.purchase \u2014 minimal required fields", "input": { - "type": "harbour_delegate:data.purchase", + "type": "harbour.delegate:data.purchase", "credential_ids": [ "harbour_natural_person" ], @@ -20,16 +20,16 @@ "marketplace": "did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c" } }, - "canonical_json": "{\"credential_ids\":[\"harbour_natural_person\"],\"iat\":1771934400,\"nonce\":\"da9b1009\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"asset_id\":\"urn:uuid:550e8400-e29b-41d4-a716-446655440000\",\"currency\":\"ENVITED\",\"marketplace\":\"did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c\",\"price\":\"100\"},\"type\":\"harbour_delegate:data.purchase\"}", - "sha256_hash": "c8f13987d6d597a8124837ee456deef6b3f132cbb373e8daedf0e00afd2120d1", - "challenge": "da9b1009 HARBOUR_DELEGATE c8f13987d6d597a8124837ee456deef6b3f132cbb373e8daedf0e00afd2120d1", - "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJoYXJib3VyX25hdHVyYWxfcGVyc29uIl0sImlhdCI6MTc3MTkzNDQwMCwibm9uY2UiOiJkYTliMTAwOSIsInRyYW5zYWN0aW9uX2RhdGFfaGFzaGVzX2FsZyI6WyJzaGEtMjU2Il0sInR4biI6eyJhc3NldF9pZCI6InVybjp1dWlkOjU1MGU4NDAwLWUyOWItNDFkNC1hNzE2LTQ0NjY1NTQ0MDAwMCIsImN1cnJlbmN5IjoiRU5WSVRFRCIsIm1hcmtldHBsYWNlIjoiZGlkOmV0aHI6MHgxNGEzNDoweDg5ZmU1ZTdmNTA2ZDk5MmY3NmJjYmEzMDk3NzNjMGVlM2VlNjAzOWMiLCJwcmljZSI6IjEwMCJ9LCJ0eXBlIjoiaGFyYm91cl9kZWxlZ2F0ZTpkYXRhLnB1cmNoYXNlIn0", - "transaction_data_param_hash": "ZZ9AEyLSxEiIESLwaWBwgQFpTKc_BCgIJooVa4XUS4A" + "canonical_json": "{\"credential_ids\":[\"harbour_natural_person\"],\"iat\":1771934400,\"nonce\":\"da9b1009\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"asset_id\":\"urn:uuid:550e8400-e29b-41d4-a716-446655440000\",\"currency\":\"ENVITED\",\"marketplace\":\"did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c\",\"price\":\"100\"},\"type\":\"harbour.delegate:data.purchase\"}", + "sha256_hash": "c3d4ba771c1103935ab4121874c4b3a78c8471719c80f60d59ca5811e232089b", + "challenge": "da9b1009 HARBOUR_DELEGATE c3d4ba771c1103935ab4121874c4b3a78c8471719c80f60d59ca5811e232089b", + "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJoYXJib3VyX25hdHVyYWxfcGVyc29uIl0sImlhdCI6MTc3MTkzNDQwMCwibm9uY2UiOiJkYTliMTAwOSIsInRyYW5zYWN0aW9uX2RhdGFfaGFzaGVzX2FsZyI6WyJzaGEtMjU2Il0sInR4biI6eyJhc3NldF9pZCI6InVybjp1dWlkOjU1MGU4NDAwLWUyOWItNDFkNC1hNzE2LTQ0NjY1NTQ0MDAwMCIsImN1cnJlbmN5IjoiRU5WSVRFRCIsIm1hcmtldHBsYWNlIjoiZGlkOmV0aHI6MHgxNGEzNDoweDg5ZmU1ZTdmNTA2ZDk5MmY3NmJjYmEzMDk3NzNjMGVlM2VlNjAzOWMiLCJwcmljZSI6IjEwMCJ9LCJ0eXBlIjoiaGFyYm91ci5kZWxlZ2F0ZTpkYXRhLnB1cmNoYXNlIn0", + "transaction_data_param_hash": "iLNAGDcp7egLLCTrab_aLdRTUuGVqd1rhHnL8hXU5KI" }, { "name": "contract.sign \u2014 with optional exp and description", "input": { - "type": "harbour_delegate:contract.sign", + "type": "harbour.delegate:contract.sign", "credential_ids": [ "org_credential" ], @@ -48,16 +48,16 @@ ] } }, - "canonical_json": "{\"credential_ids\":[\"org_credential\"],\"description\":\"Sign partnership agreement\",\"exp\":1771935300,\"iat\":1771934400,\"nonce\":\"ab12cd34\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"document_hash\":\"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"parties\":[\"did:ethr:0x14a34:0x9b280b503a94d1e43ebd8ee2549236c00748dace\",\"did:ethr:0x14a34:0x87fdc0cc3b127f964d7651b0d55362663104b892\"]},\"type\":\"harbour_delegate:contract.sign\"}", - "sha256_hash": "d5b99dc48015407f56244eaac0912446060daefee19eadf7183ba41de71fd9cb", - "challenge": "ab12cd34 HARBOUR_DELEGATE d5b99dc48015407f56244eaac0912446060daefee19eadf7183ba41de71fd9cb", - "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJvcmdfY3JlZGVudGlhbCJdLCJkZXNjcmlwdGlvbiI6IlNpZ24gcGFydG5lcnNoaXAgYWdyZWVtZW50IiwiZXhwIjoxNzcxOTM1MzAwLCJpYXQiOjE3NzE5MzQ0MDAsIm5vbmNlIjoiYWIxMmNkMzQiLCJ0cmFuc2FjdGlvbl9kYXRhX2hhc2hlc19hbGciOlsic2hhLTI1NiJdLCJ0eG4iOnsiZG9jdW1lbnRfaGFzaCI6InNoYTI1NjplM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1IiwicGFydGllcyI6WyJkaWQ6ZXRocjoweDE0YTM0OjB4OWIyODBiNTAzYTk0ZDFlNDNlYmQ4ZWUyNTQ5MjM2YzAwNzQ4ZGFjZSIsImRpZDpldGhyOjB4MTRhMzQ6MHg4N2ZkYzBjYzNiMTI3Zjk2NGQ3NjUxYjBkNTUzNjI2NjMxMDRiODkyIl19LCJ0eXBlIjoiaGFyYm91cl9kZWxlZ2F0ZTpjb250cmFjdC5zaWduIn0", - "transaction_data_param_hash": "a3_WNgJY8-zqosLSwrlgqS-w70WnnX0FOsbcFh5fgfY" + "canonical_json": "{\"credential_ids\":[\"org_credential\"],\"description\":\"Sign partnership agreement\",\"exp\":1771935300,\"iat\":1771934400,\"nonce\":\"ab12cd34\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"document_hash\":\"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\",\"parties\":[\"did:ethr:0x14a34:0x9b280b503a94d1e43ebd8ee2549236c00748dace\",\"did:ethr:0x14a34:0x87fdc0cc3b127f964d7651b0d55362663104b892\"]},\"type\":\"harbour.delegate:contract.sign\"}", + "sha256_hash": "573cc3da4d63242b2d8b950b29507b9b1e414d9330d3bb245ce7fb264b259601", + "challenge": "ab12cd34 HARBOUR_DELEGATE 573cc3da4d63242b2d8b950b29507b9b1e414d9330d3bb245ce7fb264b259601", + "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJvcmdfY3JlZGVudGlhbCJdLCJkZXNjcmlwdGlvbiI6IlNpZ24gcGFydG5lcnNoaXAgYWdyZWVtZW50IiwiZXhwIjoxNzcxOTM1MzAwLCJpYXQiOjE3NzE5MzQ0MDAsIm5vbmNlIjoiYWIxMmNkMzQiLCJ0cmFuc2FjdGlvbl9kYXRhX2hhc2hlc19hbGciOlsic2hhLTI1NiJdLCJ0eG4iOnsiZG9jdW1lbnRfaGFzaCI6InNoYTI1NjplM2IwYzQ0Mjk4ZmMxYzE0OWFmYmY0Yzg5OTZmYjkyNDI3YWU0MWU0NjQ5YjkzNGNhNDk1OTkxYjc4NTJiODU1IiwicGFydGllcyI6WyJkaWQ6ZXRocjoweDE0YTM0OjB4OWIyODBiNTAzYTk0ZDFlNDNlYmQ4ZWUyNTQ5MjM2YzAwNzQ4ZGFjZSIsImRpZDpldGhyOjB4MTRhMzQ6MHg4N2ZkYzBjYzNiMTI3Zjk2NGQ3NjUxYjBkNTUzNjI2NjMxMDRiODkyIl19LCJ0eXBlIjoiaGFyYm91ci5kZWxlZ2F0ZTpjb250cmFjdC5zaWduIn0", + "transaction_data_param_hash": "OYAKkGIz-uN1FqyqSz9BtJjiqJcYnRZFwl9NhqLVSFE" }, { "name": "blockchain.transfer \u2014 nested txn verifies recursive sort", "input": { - "type": "harbour_delegate:blockchain.transfer", + "type": "harbour.delegate:blockchain.transfer", "credential_ids": [ "default" ], @@ -73,16 +73,16 @@ "contract": "0x1234567890abcdef" } }, - "canonical_json": "{\"credential_ids\":[\"default\"],\"iat\":1771934400,\"nonce\":\"ef567890\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"amount\":\"1000000000000000000\",\"chain\":\"eip155:42793\",\"contract\":\"0x1234567890abcdef\",\"recipient\":\"0xabcdef1234567890\"},\"type\":\"harbour_delegate:blockchain.transfer\"}", - "sha256_hash": "0736db89c15be412294f96717a3e435f89d095e7e953b1808c422252b845d4c1", - "challenge": "ef567890 HARBOUR_DELEGATE 0736db89c15be412294f96717a3e435f89d095e7e953b1808c422252b845d4c1", - "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJkZWZhdWx0Il0sImlhdCI6MTc3MTkzNDQwMCwibm9uY2UiOiJlZjU2Nzg5MCIsInRyYW5zYWN0aW9uX2RhdGFfaGFzaGVzX2FsZyI6WyJzaGEtMjU2Il0sInR4biI6eyJhbW91bnQiOiIxMDAwMDAwMDAwMDAwMDAwMDAwIiwiY2hhaW4iOiJlaXAxNTU6NDI3OTMiLCJjb250cmFjdCI6IjB4MTIzNDU2Nzg5MGFiY2RlZiIsInJlY2lwaWVudCI6IjB4YWJjZGVmMTIzNDU2Nzg5MCJ9LCJ0eXBlIjoiaGFyYm91cl9kZWxlZ2F0ZTpibG9ja2NoYWluLnRyYW5zZmVyIn0", - "transaction_data_param_hash": "zjzDdgBNR_BsX0TR5nOKIhZLC-RUj92EFJd-bKbgwtw" + "canonical_json": "{\"credential_ids\":[\"default\"],\"iat\":1771934400,\"nonce\":\"ef567890\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"amount\":\"1000000000000000000\",\"chain\":\"eip155:42793\",\"contract\":\"0x1234567890abcdef\",\"recipient\":\"0xabcdef1234567890\"},\"type\":\"harbour.delegate:blockchain.transfer\"}", + "sha256_hash": "66d8768b6f6ae9d952f61c85414d22d504341da5d0ff0f65a45398246f1f630a", + "challenge": "ef567890 HARBOUR_DELEGATE 66d8768b6f6ae9d952f61c85414d22d504341da5d0ff0f65a45398246f1f630a", + "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJkZWZhdWx0Il0sImlhdCI6MTc3MTkzNDQwMCwibm9uY2UiOiJlZjU2Nzg5MCIsInRyYW5zYWN0aW9uX2RhdGFfaGFzaGVzX2FsZyI6WyJzaGEtMjU2Il0sInR4biI6eyJhbW91bnQiOiIxMDAwMDAwMDAwMDAwMDAwMDAwIiwiY2hhaW4iOiJlaXAxNTU6NDI3OTMiLCJjb250cmFjdCI6IjB4MTIzNDU2Nzg5MGFiY2RlZiIsInJlY2lwaWVudCI6IjB4YWJjZGVmMTIzNDU2Nzg5MCJ9LCJ0eXBlIjoiaGFyYm91ci5kZWxlZ2F0ZTpibG9ja2NoYWluLnRyYW5zZmVyIn0", + "transaction_data_param_hash": "ttsN0Ul4X-87rncQAUoPJDixbyC6vNYEM67usPjv0Fg" }, { "name": "blockchain.execute \u2014 deeply nested params and booleans", "input": { - "type": "harbour_delegate:blockchain.execute", + "type": "harbour.delegate:blockchain.execute", "credential_ids": [ "wallet_cred", "org_cred" @@ -112,11 +112,11 @@ "value": "0" } }, - "canonical_json": "{\"credential_ids\":[\"wallet_cred\",\"org_cred\"],\"description\":\"Execute settlement\",\"iat\":1771934400,\"nonce\":\"91af4c2e\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"chain\":\"eip155:1\",\"contract\":\"0x1234567890abcdef1234567890abcdef12345678\",\"method\":\"settle\",\"params\":{\"amount\":\"4200000000000000000\",\"approvers\":[\"did:ethr:0x14a34:0x081d85aa2de20b04cf2e2114b56d7a3c025f69c1\",\"did:ethr:0x14a34:0x59404f9182101ca5c3e4b3c5dab9fb25bfa0b9ba\"],\"flags\":{\"gasless\":false,\"urgent\":true},\"recipient\":\"0xAbCdEf1234567890aBCDef1234567890abCDef12\"},\"value\":\"0\"},\"type\":\"harbour_delegate:blockchain.execute\"}", - "sha256_hash": "a00d54fed8213850dccbea6b4ecdf5dbec16156bc51c0a135c56e536e27f7b7a", - "challenge": "91af4c2e HARBOUR_DELEGATE a00d54fed8213850dccbea6b4ecdf5dbec16156bc51c0a135c56e536e27f7b7a", - "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJ3YWxsZXRfY3JlZCIsIm9yZ19jcmVkIl0sImRlc2NyaXB0aW9uIjoiRXhlY3V0ZSBzZXR0bGVtZW50IiwiaWF0IjoxNzcxOTM0NDAwLCJub25jZSI6IjkxYWY0YzJlIiwidHJhbnNhY3Rpb25fZGF0YV9oYXNoZXNfYWxnIjpbInNoYS0yNTYiXSwidHhuIjp7ImNoYWluIjoiZWlwMTU1OjEiLCJjb250cmFjdCI6IjB4MTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTBhYmNkZWYxMjM0NTY3OCIsIm1ldGhvZCI6InNldHRsZSIsInBhcmFtcyI6eyJhbW91bnQiOiI0MjAwMDAwMDAwMDAwMDAwMDAwIiwiYXBwcm92ZXJzIjpbImRpZDpldGhyOjB4MTRhMzQ6MHgwODFkODVhYTJkZTIwYjA0Y2YyZTIxMTRiNTZkN2EzYzAyNWY2OWMxIiwiZGlkOmV0aHI6MHgxNGEzNDoweDU5NDA0ZjkxODIxMDFjYTVjM2U0YjNjNWRhYjlmYjI1YmZhMGI5YmEiXSwiZmxhZ3MiOnsiZ2FzbGVzcyI6ZmFsc2UsInVyZ2VudCI6dHJ1ZX0sInJlY2lwaWVudCI6IjB4QWJDZEVmMTIzNDU2Nzg5MGFCQ0RlZjEyMzQ1Njc4OTBhYkNEZWYxMiJ9LCJ2YWx1ZSI6IjAifSwidHlwZSI6ImhhcmJvdXJfZGVsZWdhdGU6YmxvY2tjaGFpbi5leGVjdXRlIn0", - "transaction_data_param_hash": "k3i9tdr-DjCyQZOBLL1wcY9CIFFbBctvnTtOjpbbyws" + "canonical_json": "{\"credential_ids\":[\"wallet_cred\",\"org_cred\"],\"description\":\"Execute settlement\",\"iat\":1771934400,\"nonce\":\"91af4c2e\",\"transaction_data_hashes_alg\":[\"sha-256\"],\"txn\":{\"chain\":\"eip155:1\",\"contract\":\"0x1234567890abcdef1234567890abcdef12345678\",\"method\":\"settle\",\"params\":{\"amount\":\"4200000000000000000\",\"approvers\":[\"did:ethr:0x14a34:0x081d85aa2de20b04cf2e2114b56d7a3c025f69c1\",\"did:ethr:0x14a34:0x59404f9182101ca5c3e4b3c5dab9fb25bfa0b9ba\"],\"flags\":{\"gasless\":false,\"urgent\":true},\"recipient\":\"0xAbCdEf1234567890aBCDef1234567890abCDef12\"},\"value\":\"0\"},\"type\":\"harbour.delegate:blockchain.execute\"}", + "sha256_hash": "fbe4104979d0dd72cccad71a0ebd4ea32dfeb667d096588e5d6604a23b5319ce", + "challenge": "91af4c2e HARBOUR_DELEGATE fbe4104979d0dd72cccad71a0ebd4ea32dfeb667d096588e5d6604a23b5319ce", + "transaction_data_param": "eyJjcmVkZW50aWFsX2lkcyI6WyJ3YWxsZXRfY3JlZCIsIm9yZ19jcmVkIl0sImRlc2NyaXB0aW9uIjoiRXhlY3V0ZSBzZXR0bGVtZW50IiwiaWF0IjoxNzcxOTM0NDAwLCJub25jZSI6IjkxYWY0YzJlIiwidHJhbnNhY3Rpb25fZGF0YV9oYXNoZXNfYWxnIjpbInNoYS0yNTYiXSwidHhuIjp7ImNoYWluIjoiZWlwMTU1OjEiLCJjb250cmFjdCI6IjB4MTIzNDU2Nzg5MGFiY2RlZjEyMzQ1Njc4OTBhYmNkZWYxMjM0NTY3OCIsIm1ldGhvZCI6InNldHRsZSIsInBhcmFtcyI6eyJhbW91bnQiOiI0MjAwMDAwMDAwMDAwMDAwMDAwIiwiYXBwcm92ZXJzIjpbImRpZDpldGhyOjB4MTRhMzQ6MHgwODFkODVhYTJkZTIwYjA0Y2YyZTIxMTRiNTZkN2EzYzAyNWY2OWMxIiwiZGlkOmV0aHI6MHgxNGEzNDoweDU5NDA0ZjkxODIxMDFjYTVjM2U0YjNjNWRhYjlmYjI1YmZhMGI5YmEiXSwiZmxhZ3MiOnsiZ2FzbGVzcyI6ZmFsc2UsInVyZ2VudCI6dHJ1ZX0sInJlY2lwaWVudCI6IjB4QWJDZEVmMTIzNDU2Nzg5MGFCQ0RlZjEyMzQ1Njc4OTBhYkNEZWYxMiJ9LCJ2YWx1ZSI6IjAifSwidHlwZSI6ImhhcmJvdXIuZGVsZWdhdGU6YmxvY2tjaGFpbi5leGVjdXRlIn0", + "transaction_data_param_hash": "fuKDy_Za4yH8EJUleXCG5WG0A-dAkY5SHqQoIQbH1-E" } ] } diff --git a/tests/fixtures/sample-vc.json b/tests/fixtures/sample-vc.json index 500dc2b..e4fcf7a 100644 --- a/tests/fixtures/sample-vc.json +++ b/tests/fixtures/sample-vc.json @@ -1,7 +1,7 @@ { "@context": [ "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/reachhaven/harbour/credentials/v1/" + "https://w3id.org/reachhaven/harbour/core/v1/" ], "type": ["VerifiableCredential"], "id": "urn:uuid:576fbefb-35e8-4b71-bb1a-53d1803c86de", diff --git a/tests/python/credentials/test_claim_mapping.py b/tests/python/credentials/test_claim_mapping.py index f6d860b..3785f55 100644 --- a/tests/python/credentials/test_claim_mapping.py +++ b/tests/python/credentials/test_claim_mapping.py @@ -35,7 +35,7 @@ def _load_fixture(name: str) -> dict: class TestGaiaxLegalPersonMapping: def test_vc_to_claims(self): vc = _load_fixture("legal-person-credential.json") - mapping = MAPPINGS["harbour_gx:LegalPersonCredential"] + mapping = MAPPINGS["harbour.gx:LegalPersonCredential"] claims, disclosable = vc_to_sd_jwt_claims(vc, mapping) assert ( @@ -46,9 +46,9 @@ def test_vc_to_claims(self): claims["sub"] == "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" ) - assert claims["legalName"] == "Example Corporation GmbH" - assert "registrationNumber" in claims - assert "registrationNumber" in disclosable + assert claims["labelLevel"] == "SC" + assert "engineVersion" in claims + assert "engineVersion" in disclosable def test_has_credential_status(self): vc = _load_fixture("legal-person-credential.json") @@ -58,10 +58,10 @@ def test_has_credential_status(self): assert status["statusPurpose"] == "revocation" def test_subject_is_harbour_gx_legal_person(self): - """Verify the subject uses harbour_gx:LegalPerson.""" + """Verify the subject uses harbour.gx:LegalPerson.""" vc = _load_fixture("legal-person-credential.json") subject_type = vc["credentialSubject"]["type"] - assert subject_type == "harbour_gx:LegalPerson" + assert subject_type == "harbour.gx:LegalPerson" def test_has_gaiax_context(self): """Gaia-X extension must include the Gaia-X namespace in @context.""" @@ -70,21 +70,21 @@ def test_has_gaiax_context(self): def test_roundtrip(self): vc = _load_fixture("legal-person-credential.json") - mapping = MAPPINGS["harbour_gx:LegalPersonCredential"] + mapping = MAPPINGS["harbour.gx:LegalPersonCredential"] claims, _ = vc_to_sd_jwt_claims(vc, mapping) reconstructed = sd_jwt_claims_to_vc( - claims, mapping, "harbour_gx:LegalPersonCredential" + claims, mapping, "harbour.gx:LegalPersonCredential" ) assert ( - reconstructed["credentialSubject"]["name"] - == vc["credentialSubject"]["name"] + reconstructed["credentialSubject"]["harbour.gx:labelLevel"] + == vc["credentialSubject"]["harbour.gx:labelLevel"] ) class TestGaiaxNaturalPersonMapping: def test_vc_to_claims(self): vc = _load_fixture("natural-person-credential.json") - mapping = MAPPINGS["harbour_gx:NaturalPersonCredential"] + mapping = MAPPINGS["harbour.gx:NaturalPersonCredential"] claims, disclosable = vc_to_sd_jwt_claims(vc, mapping) assert claims["givenName"] == "Alice" @@ -112,10 +112,10 @@ def test_has_evidence(self): assert ev_type == "harbour:CredentialEvidence" def test_subject_is_harbour_gx_natural_person(self): - """Verify the subject uses harbour_gx:NaturalPerson.""" + """Verify the subject uses harbour.gx:NaturalPerson.""" vc = _load_fixture("natural-person-credential.json") subject_type = vc["credentialSubject"]["type"] - assert subject_type == "harbour_gx:NaturalPerson" + assert subject_type == "harbour.gx:NaturalPerson" def test_has_gaiax_context(self): """Gaia-X extension must include the Gaia-X namespace in @context.""" @@ -135,7 +135,7 @@ def test_get_mapping_for_gaiax_legal_person(self): mapping = get_mapping_for_vc(vc) assert mapping is not None assert "LegalPersonCredential" in mapping["vct"] - assert "credentialSubject.name" in mapping["claims"] + assert "credentialSubject.harbour\\.gx:labelLevel" in mapping["claims"] def test_get_mapping_for_gaiax_natural_person(self): """Gaia-X natural person should return the flat mapping.""" diff --git a/tests/python/credentials/test_example_signer.py b/tests/python/credentials/test_example_signer.py index 4df2713..0c9cdf3 100644 --- a/tests/python/credentials/test_example_signer.py +++ b/tests/python/credentials/test_example_signer.py @@ -149,7 +149,7 @@ def test_process_example_with_evidence(self, signing_key, tmp_path): vc_jwt = jwt_path.read_text().strip() vc_payload = verify_vc_jose(vc_jwt, public_key) assert vc_payload["id"] == "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890" - assert "harbour_gx:LegalPersonCredential" in vc_payload["type"] + assert "harbour.gx:LegalPersonCredential" in vc_payload["type"] # Evidence should now be a JWT string evidence = vc_payload["evidence"][0] @@ -190,7 +190,7 @@ def test_process_delegated_signing_receipt(self, signing_key, tmp_path): else: assert ev_type == "harbour:DelegatedSignatureEvidence" assert "transaction_data" in evidence - assert evidence["transaction_data"]["type"] == "harbour_delegate:data.purchase" + assert evidence["transaction_data"]["type"] == "harbour.delegate:data.purchase" assert ( evidence["delegatedTo"] == "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697" @@ -242,12 +242,12 @@ def test_process_gaiax_legal_person(self, signing_key, tmp_path): # Verify outer VC JWT vc_jwt = jwt_path.read_text().strip() vc_payload = verify_vc_jose(vc_jwt, public_key) - assert "harbour_gx:LegalPersonCredential" in vc_payload["type"] + assert "harbour.gx:LegalPersonCredential" in vc_payload["type"] - # Subject should have the LegalPerson data directly + # Subject should have compliance data (no entity data) subject = vc_payload["credentialSubject"] - assert subject["type"] == "harbour_gx:LegalPerson" - assert "name" in subject + assert subject["type"] == "harbour.gx:LegalPerson" + assert "harbour.gx:labelLevel" in subject def test_process_gaiax_natural_person(self, signing_key, tmp_path): """Process the Gaia-X natural person credential through the pipeline.""" @@ -263,11 +263,11 @@ def test_process_gaiax_natural_person(self, signing_key, tmp_path): assert jwt_path.exists() vc_jwt = jwt_path.read_text().strip() vc_payload = verify_vc_jose(vc_jwt, public_key) - assert "harbour_gx:NaturalPersonCredential" in vc_payload["type"] + assert "harbour.gx:NaturalPersonCredential" in vc_payload["type"] # Subject should have the NaturalPerson data directly subject = vc_payload["credentialSubject"] - assert subject["type"] == "harbour_gx:NaturalPerson" + assert subject["type"] == "harbour.gx:NaturalPerson" assert "givenName" in subject def test_process_all_gaiax_examples(self, signing_key, tmp_path): diff --git a/tests/python/credentials/test_validation.py b/tests/python/credentials/test_validation.py index 15feb4a..d7b08ca 100644 --- a/tests/python/credentials/test_validation.py +++ b/tests/python/credentials/test_validation.py @@ -130,15 +130,20 @@ def test_credential_subject_has_type(credential_file): if "type" not in subject: return subject_type = subject["type"] + # Gaia-X compliance credentials (TermsAndConditions, RegistrationNumber, + # Compliance) use gx: types directly on credentialSubject because the gx + # SHACL shapes are sh:closed true — harbour wrappers would add properties + # that violate the closed shape constraint. All other domain credentials + # use harbour: or harbour.gx: prefixed types. + allowed_prefixes = ("harbour:", "harbour.gx:", "gx:") if isinstance(subject_type, str): - assert subject_type.startswith("harbour:") or subject_type.startswith( - "harbour_gx:" - ), f"Subject type should be harbour-prefixed, got: {subject_type}" + assert subject_type.startswith(allowed_prefixes), ( + f"Subject type should be harbour- or gx-prefixed, got: {subject_type}" + ) elif isinstance(subject_type, list): - assert any( - t.startswith("harbour:") or t.startswith("harbour_gx:") - for t in subject_type - ), f"Subject type list should include a harbour type: {subject_type}" + assert any(t.startswith(allowed_prefixes) for t in subject_type), ( + f"Subject type list should include a harbour or gx type: {subject_type}" + ) # --------------------------------------------------------------------------- @@ -214,7 +219,7 @@ def test_context_has_domain_classes(self): def test_context_has_composition_slots(self): ctx = _load_json(DOMAIN_CONTEXT_PATH).get("@context", {}) - assert "gxParticipant" in ctx, "Missing gxParticipant in domain context" + assert "participant" in ctx, "Missing participant in domain context" def test_domain_class_iris_are_prefixed(self): ctx = _load_json(DOMAIN_CONTEXT_PATH).get("@context", {}) @@ -317,10 +322,10 @@ def test_shacl_is_non_empty(self): def test_shacl_has_domain_shapes(self): content = DOMAIN_SHACL_PATH.read_text() expected_shapes = [ - "harbour_gx:LegalPersonCredential", - "harbour_gx:NaturalPersonCredential", - "harbour_gx:LegalPerson", - "harbour_gx:NaturalPerson", + "harbour.gx:LegalPersonCredential", + "harbour.gx:NaturalPersonCredential", + "harbour.gx:LegalPerson", + "harbour.gx:NaturalPerson", ] for shape in expected_shapes: assert f"{shape} a sh:NodeShape" in content, ( @@ -334,7 +339,7 @@ def test_credential_shapes_have_required_properties(self): "LegalPersonCredential", "NaturalPersonCredential", ]: - marker = f"harbour_gx:{cred_type} a sh:NodeShape" + marker = f"harbour.gx:{cred_type} a sh:NodeShape" assert marker in content, f"Missing shape for {cred_type}" shape_start = content.index(marker) next_shape = content.find("\n\n", shape_start + 1) @@ -352,7 +357,7 @@ def test_person_credential_shapes_require_evidence(self): """LegalPersonCredential and NaturalPersonCredential must require evidence.""" content = DOMAIN_SHACL_PATH.read_text() for cred_type in ["LegalPersonCredential", "NaturalPersonCredential"]: - marker = f"harbour_gx:{cred_type} a sh:NodeShape" + marker = f"harbour.gx:{cred_type} a sh:NodeShape" assert marker in content, f"Missing shape for {cred_type}" shape_start = content.index(marker) next_shape = content.find("\n\n", shape_start + 1) diff --git a/tests/python/harbour/test_delegation.py b/tests/python/harbour/test_delegation.py index 77e0482..5d9c4b5 100644 --- a/tests/python/harbour/test_delegation.py +++ b/tests/python/harbour/test_delegation.py @@ -54,7 +54,7 @@ def test_create_basic(self): txn={"asset_id": "urn:uuid:test", "price": "100"}, ) - assert tx.type == "harbour_delegate:data.purchase" + assert tx.type == "harbour.delegate:data.purchase" assert tx.credential_ids == ["default"] assert tx.txn == {"asset_id": "urn:uuid:test", "price": "100"} assert tx.exp is None @@ -109,7 +109,7 @@ def test_action_property(self): def test_to_dict_omits_none(self): """Test TransactionData.to_dict() omits None values.""" tx = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="da9b1009", iat=1771934400, @@ -118,7 +118,7 @@ def test_to_dict_omits_none(self): d = tx.to_dict() - assert d["type"] == "harbour_delegate:data.purchase" + assert d["type"] == "harbour.delegate:data.purchase" assert d["credential_ids"] == ["default"] assert d["nonce"] == "da9b1009" assert d["iat"] == 1771934400 @@ -129,7 +129,7 @@ def test_to_dict_omits_none(self): def test_to_dict_includes_optional_when_present(self): """Test TransactionData.to_dict() includes optional fields when set.""" tx = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["harbour_natural_person"], nonce="da9b1009", iat=1771934400, @@ -145,7 +145,7 @@ def test_to_dict_includes_optional_when_present(self): def test_to_json_canonical(self): """Test canonical JSON output (sorted keys, no whitespace).""" tx = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="da9b1009", iat=1771934400, @@ -164,7 +164,7 @@ def test_to_json_canonical(self): def test_to_json_pretty(self): """Test pretty JSON output.""" tx = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="da9b1009", iat=1771934400, @@ -180,7 +180,7 @@ def test_to_json_pretty(self): def test_from_dict(self): """Test TransactionData.from_dict().""" data = { - "type": "harbour_delegate:contract.sign", + "type": "harbour.delegate:contract.sign", "credential_ids": ["org_credential"], "nonce": "ab12cd34", "iat": 1771934400, @@ -192,7 +192,7 @@ def test_from_dict(self): tx = TransactionData.from_dict(data) - assert tx.type == "harbour_delegate:contract.sign" + assert tx.type == "harbour.delegate:contract.sign" assert tx.credential_ids == ["org_credential"] assert tx.nonce == "ab12cd34" assert tx.iat == 1771934400 @@ -204,7 +204,7 @@ def test_from_json(self): """Test TransactionData.from_json().""" json_str = json.dumps( { - "type": "harbour_delegate:data.purchase", + "type": "harbour.delegate:data.purchase", "credential_ids": ["default"], "nonce": "abc12345", "iat": 1771934400, @@ -245,7 +245,7 @@ class TestHashComputation: def test_compute_hash_deterministic(self): """Test that hash computation is deterministic.""" tx1 = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="da9b1009", iat=1771934400, @@ -253,7 +253,7 @@ def test_compute_hash_deterministic(self): ) tx2 = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="da9b1009", iat=1771934400, @@ -265,7 +265,7 @@ def test_compute_hash_deterministic(self): def test_compute_hash_key_order_independent(self): """Test that hash is independent of transaction dict key order.""" tx1 = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="da9b1009", iat=1771934400, @@ -273,7 +273,7 @@ def test_compute_hash_key_order_independent(self): ) tx2 = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="da9b1009", iat=1771934400, @@ -298,7 +298,7 @@ def test_compute_hash_64_hex_chars(self): def test_compute_hash_changes_with_data(self): """Test that hash changes when data changes.""" tx1 = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="da9b1009", iat=1771934400, @@ -306,7 +306,7 @@ def test_compute_hash_changes_with_data(self): ) tx2 = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="da9b1009", iat=1771934400, @@ -318,7 +318,7 @@ def test_compute_hash_changes_with_data(self): def test_compute_hash_sensitive_to_all_fields(self): """Test that hash changes for any field change.""" base = { - "type": "harbour_delegate:data.purchase", + "type": "harbour.delegate:data.purchase", "credential_ids": ["default"], "nonce": "da9b1009", "iat": 1771934400, @@ -330,7 +330,7 @@ def test_compute_hash_sensitive_to_all_fields(self): # Test each field change produces different hash variations = [ - {"type": "harbour_delegate:data.share"}, + {"type": "harbour.delegate:data.share"}, {"credential_ids": ["other"]}, {"nonce": "different"}, {"iat": 9999999999}, @@ -421,7 +421,7 @@ class TestCreateDelegationChallenge: def test_basic_challenge(self): """Test basic challenge creation.""" tx = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="da9b1009", iat=1771934400, @@ -439,7 +439,7 @@ def test_basic_challenge(self): def test_challenge_matches_hash(self): """Test that challenge hash matches computed hash.""" tx = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="da9b1009", iat=1771934400, @@ -530,7 +530,7 @@ class TestVerifyChallenge: def test_verify_matching_challenge(self): """Test verification of matching challenge.""" tx = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="da9b1009", iat=1771934400, @@ -544,7 +544,7 @@ def test_verify_matching_challenge(self): def test_verify_mismatched_nonce(self): """Test verification fails for mismatched nonce.""" tx = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="da9b1009", iat=1771934400, @@ -559,7 +559,7 @@ def test_verify_mismatched_nonce(self): def test_verify_mismatched_hash(self): """Test verification fails for mismatched hash.""" tx = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="da9b1009", iat=1771934400, @@ -574,7 +574,7 @@ def test_verify_mismatched_hash(self): def test_verify_tampered_data(self): """Test verification fails for tampered transaction data.""" tx = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="da9b1009", iat=1771934400, @@ -625,7 +625,7 @@ def test_validate_invalid_type(self): def test_validate_short_nonce(self): """Test validation fails for short nonce.""" tx = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="abc", # Too short (< 8 chars) iat=int(time.time()), @@ -641,7 +641,7 @@ def test_validate_old_timestamp(self): """Test validation fails for old timestamp.""" old_iat = int(time.time()) - 600 # 10 minutes ago tx = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="da9b1009", iat=old_iat, @@ -657,7 +657,7 @@ def test_validate_future_timestamp(self): """Test validation fails for future timestamp.""" future_iat = int(time.time()) + 300 # 5 minutes in future tx = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="da9b1009", iat=future_iat, @@ -688,7 +688,7 @@ def test_validate_custom_max_age(self): # 2 minutes old old_iat = int(time.time()) - 120 tx = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="da9b1009", iat=old_iat, @@ -714,7 +714,7 @@ class TestRenderTransactionDisplay: def test_render_basic(self): """Test basic display rendering.""" tx = TransactionData( - type="harbour_delegate:data.purchase", + type="harbour.delegate:data.purchase", credential_ids=["default"], nonce="da9b1009", iat=1771934400, @@ -746,7 +746,7 @@ def test_render_custom_service_name(self): def test_render_unknown_action(self): """Test display with unknown action type.""" tx = TransactionData( - type="harbour_delegate:unknown.action", + type="harbour.delegate:unknown.action", credential_ids=["default"], nonce="da9b1009", iat=1771934400, diff --git a/tests/python/harbour/test_kb_jwt.py b/tests/python/harbour/test_kb_jwt.py index c49b527..dbfc718 100644 --- a/tests/python/harbour/test_kb_jwt.py +++ b/tests/python/harbour/test_kb_jwt.py @@ -17,7 +17,7 @@ "email": "info@example.com", } -VCT = "https://w3id.org/reachhaven/harbour/credentials/v1/LegalPersonCredential" +VCT = "https://w3id.org/reachhaven/harbour/core/v1/LegalPersonCredential" @pytest.fixture() diff --git a/tests/python/harbour/test_sd_jwt.py b/tests/python/harbour/test_sd_jwt.py index 9b3b1b4..82e52c7 100644 --- a/tests/python/harbour/test_sd_jwt.py +++ b/tests/python/harbour/test_sd_jwt.py @@ -20,7 +20,7 @@ "email": "info@example.com", } -VCT = "https://w3id.org/reachhaven/harbour/credentials/v1/LegalPersonCredential" +VCT = "https://w3id.org/reachhaven/harbour/core/v1/LegalPersonCredential" class TestSDJWTVCIssuance: diff --git a/tests/python/harbour/test_sd_jwt_vp.py b/tests/python/harbour/test_sd_jwt_vp.py index c948d96..25e0783 100644 --- a/tests/python/harbour/test_sd_jwt_vp.py +++ b/tests/python/harbour/test_sd_jwt_vp.py @@ -160,7 +160,7 @@ def test_issue_with_evidence(self, sample_sd_jwt_vc, holder_keypair): { "type": "DelegatedSignatureEvidence", "transaction_data": { - "type": "harbour_delegate:data.purchase", + "type": "harbour.delegate:data.purchase", "credential_ids": ["harbour_natural_person"], "nonce": tx_nonce, "iat": 1771934400, @@ -206,7 +206,7 @@ def test_issue_keeps_transaction_data_field(self, sample_sd_jwt_vc, holder_keypa { "type": "DelegatedSignatureEvidence", "transaction_data": { - "type": "harbour_delegate:data.purchase", + "type": "harbour.delegate:data.purchase", "credential_ids": ["default"], "nonce": "snake-nonce", "iat": 1771934400, @@ -331,7 +331,7 @@ def test_verify_evidence(self, sample_sd_jwt_vc, issuer_keypair, holder_keypair) { "type": "DelegatedSignatureEvidence", "transaction_data": { - "type": "harbour_delegate:blockchain.approve", + "type": "harbour.delegate:blockchain.approve", "credential_ids": ["default"], "nonce": "unique-consent-nonce", "iat": 1771934400, @@ -364,7 +364,7 @@ def test_verify_fails_transaction_hash_mismatch( { "type": "DelegatedSignatureEvidence", "transaction_data": { - "type": "harbour_delegate:data.purchase", + "type": "harbour.delegate:data.purchase", "credential_ids": ["default"], "nonce": nonce, "iat": 1771934400, @@ -423,7 +423,7 @@ def test_verify_fails_when_transaction_data_missing( { "type": "DelegatedSignatureEvidence", "transaction_data": { - "type": "harbour_delegate:data.purchase", + "type": "harbour.delegate:data.purchase", "credential_ids": ["default"], "nonce": nonce, "iat": 1771934400, @@ -547,7 +547,7 @@ def test_delegated_consent_flow(self, issuer_keypair, holder_keypair): consent_nonce = secrets.token_urlsafe(32) transaction_data = { - "type": "harbour_delegate:data.purchase", + "type": "harbour.delegate:data.purchase", "credential_ids": ["harbour_natural_person"], "nonce": consent_nonce, "iat": 1771934400, @@ -606,7 +606,7 @@ def test_delegated_consent_flow(self, issuer_keypair, holder_keypair): assert len(result["evidence"]) == 1 ev = result["evidence"][0] assert ev["type"] == "DelegatedSignatureEvidence" - assert ev["transaction_data"]["type"] == "harbour_delegate:data.purchase" + assert ev["transaction_data"]["type"] == "harbour.delegate:data.purchase" assert ev["transaction_data"]["nonce"] == consent_nonce assert ev["challenge"] == create_delegation_challenge( TransactionData.from_dict(transaction_data) @@ -640,7 +640,7 @@ def test_public_audit_privacy(self, issuer_keypair, holder_keypair): { "type": "DelegatedSignatureEvidence", "transaction_data": { - "type": "harbour_delegate:blockchain.transfer", + "type": "harbour.delegate:blockchain.transfer", "credential_ids": ["default"], "nonce": "public-audit-nonce", "iat": 1771934400, @@ -720,7 +720,7 @@ def test_multiple_evidence_items(self, sample_sd_jwt_vc, holder_keypair): { "type": "DelegatedSignatureEvidence", "transaction_data": { - "type": "harbour_delegate:data.share", + "type": "harbour.delegate:data.share", "credential_ids": ["default"], "nonce": "multi-evidence-nonce", "iat": 1771934400, diff --git a/tests/typescript/harbour/delegation.test.ts b/tests/typescript/harbour/delegation.test.ts index a9da66a..b157461 100644 --- a/tests/typescript/harbour/delegation.test.ts +++ b/tests/typescript/harbour/delegation.test.ts @@ -47,7 +47,7 @@ describe("TransactionData", () => { txn: { asset_id: "urn:uuid:test", price: "100" }, }); - expect(tx.type).toBe("harbour_delegate:data.purchase"); + expect(tx.type).toBe("harbour.delegate:data.purchase"); expect(tx.credential_ids).toEqual(["default"]); expect(tx.txn).toEqual({ asset_id: "urn:uuid:test", price: "100" }); expect(tx.exp).toBeUndefined(); @@ -105,7 +105,7 @@ describe("TransactionData", () => { describe("Canonical JSON", () => { it("sorts keys recursively", () => { const tx: TransactionData = { - type: "harbour_delegate:data.purchase", + type: "harbour.delegate:data.purchase", credential_ids: ["default"], nonce: "da9b1009", iat: 1771934400, @@ -125,7 +125,7 @@ describe("Canonical JSON", () => { it("produces deterministic hashes", async () => { const tx1: TransactionData = { - type: "harbour_delegate:data.purchase", + type: "harbour.delegate:data.purchase", credential_ids: ["default"], nonce: "da9b1009", iat: 1771934400, @@ -145,7 +145,7 @@ describe("Canonical JSON", () => { it("is independent of key insertion order", async () => { const tx1: TransactionData = { - type: "harbour_delegate:data.purchase", + type: "harbour.delegate:data.purchase", credential_ids: ["default"], nonce: "da9b1009", iat: 1771934400, @@ -154,7 +154,7 @@ describe("Canonical JSON", () => { }; const tx2: TransactionData = { - type: "harbour_delegate:data.purchase", + type: "harbour.delegate:data.purchase", credential_ids: ["default"], nonce: "da9b1009", iat: 1771934400, @@ -169,7 +169,7 @@ describe("Canonical JSON", () => { it("changes hash when data changes", async () => { const tx1: TransactionData = { - type: "harbour_delegate:data.purchase", + type: "harbour.delegate:data.purchase", credential_ids: ["default"], nonce: "da9b1009", iat: 1771934400, @@ -239,7 +239,7 @@ describe("Shared canonicalization vectors", () => { describe("createDelegationChallenge", () => { it("creates a valid challenge", async () => { const tx: TransactionData = { - type: "harbour_delegate:data.purchase", + type: "harbour.delegate:data.purchase", credential_ids: ["default"], nonce: "da9b1009", iat: 1771934400, @@ -311,7 +311,7 @@ describe("parseDelegationChallenge", () => { describe("verifyChallenge", () => { it("verifies matching challenge", async () => { const tx: TransactionData = { - type: "harbour_delegate:data.purchase", + type: "harbour.delegate:data.purchase", credential_ids: ["default"], nonce: "da9b1009", iat: 1771934400, @@ -325,7 +325,7 @@ describe("verifyChallenge", () => { it("fails for mismatched nonce", async () => { const tx: TransactionData = { - type: "harbour_delegate:data.purchase", + type: "harbour.delegate:data.purchase", credential_ids: ["default"], nonce: "da9b1009", iat: 1771934400, @@ -340,7 +340,7 @@ describe("verifyChallenge", () => { it("fails for mismatched hash", async () => { const tx: TransactionData = { - type: "harbour_delegate:data.purchase", + type: "harbour.delegate:data.purchase", credential_ids: ["default"], nonce: "da9b1009", iat: 1771934400, @@ -380,7 +380,7 @@ describe("validateTransactionData", () => { it("throws for short nonce", () => { const tx: TransactionData = { - type: "harbour_delegate:data.purchase", + type: "harbour.delegate:data.purchase", credential_ids: ["default"], nonce: "abc", iat: Math.floor(Date.now() / 1000), @@ -392,7 +392,7 @@ describe("validateTransactionData", () => { it("throws for old timestamp", () => { const tx: TransactionData = { - type: "harbour_delegate:data.purchase", + type: "harbour.delegate:data.purchase", credential_ids: ["default"], nonce: "da9b1009", iat: Math.floor(Date.now() / 1000) - 600, @@ -406,7 +406,7 @@ describe("validateTransactionData", () => { it("throws for future timestamp", () => { const tx: TransactionData = { - type: "harbour_delegate:data.purchase", + type: "harbour.delegate:data.purchase", credential_ids: ["default"], nonce: "da9b1009", iat: Math.floor(Date.now() / 1000) + 300, @@ -433,7 +433,7 @@ describe("validateTransactionData", () => { describe("renderTransactionDisplay", () => { it("renders basic display", () => { const tx: TransactionData = { - type: "harbour_delegate:data.purchase", + type: "harbour.delegate:data.purchase", credential_ids: ["default"], nonce: "da9b1009", iat: 1771934400, diff --git a/tests/typescript/harbour/sd-jwt-vp.test.ts b/tests/typescript/harbour/sd-jwt-vp.test.ts index c3cda5a..cb3bbba 100644 --- a/tests/typescript/harbour/sd-jwt-vp.test.ts +++ b/tests/typescript/harbour/sd-jwt-vp.test.ts @@ -126,7 +126,7 @@ describe("issueSdJwtVp", () => { { type: "DelegatedSignatureEvidence", transaction_data: { - type: "harbour_delegate:data.purchase", + type: "harbour.delegate:data.purchase", credential_ids: ["harbour_natural_person"], nonce: txNonce, iat: 1771934400, @@ -169,7 +169,7 @@ describe("issueSdJwtVp", () => { { type: "DelegatedSignatureEvidence", transaction_data: { - type: "harbour_delegate:data.purchase", + type: "harbour.delegate:data.purchase", credential_ids: ["default"], nonce: "snake-nonce", iat: 1771934400, @@ -255,7 +255,7 @@ describe("verifySdJwtVp", () => { { type: "DelegatedSignatureEvidence", transaction_data: { - type: "harbour_delegate:blockchain.approve", + type: "harbour.delegate:blockchain.approve", credential_ids: ["default"], nonce: "consent-nonce", iat: 1771934400, @@ -277,7 +277,7 @@ describe("verifySdJwtVp", () => { { type: "DelegatedSignatureEvidence", transaction_data: { - type: "harbour_delegate:data.purchase", + type: "harbour.delegate:data.purchase", credential_ids: ["default"], nonce, iat: 1771934400, @@ -332,7 +332,7 @@ describe("verifySdJwtVp", () => { { type: "DelegatedSignatureEvidence", transaction_data: { - type: "harbour_delegate:data.purchase", + type: "harbour.delegate:data.purchase", credential_ids: ["default"], nonce, iat: 1771934400, diff --git a/tests/typescript/harbour/sd-jwt.test.ts b/tests/typescript/harbour/sd-jwt.test.ts index 3e447c8..5637023 100644 --- a/tests/typescript/harbour/sd-jwt.test.ts +++ b/tests/typescript/harbour/sd-jwt.test.ts @@ -12,7 +12,7 @@ import { const FIXTURES_DIR = resolve(__dirname, "../../fixtures"); const VCT = - "https://w3id.org/reachhaven/harbour/credentials/v1/LegalPersonCredential"; + "https://w3id.org/reachhaven/harbour/core/v1/LegalPersonCredential"; const SAMPLE_CLAIMS = { iss: "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", diff --git a/tests/validation-probe/ontology-loading-probe.json b/tests/validation-probe/ontology-loading-probe.json index 51af546..7ea0e47 100644 --- a/tests/validation-probe/ontology-loading-probe.json +++ b/tests/validation-probe/ontology-loading-probe.json @@ -1,11 +1,11 @@ { "@context": { - "@vocab": "https://w3id.org/reachhaven/harbour/credentials/v1/", + "@vocab": "https://w3id.org/reachhaven/harbour/core/v1/", "gx": "https://w3id.org/gaia-x/development#" }, "@id": "urn:uuid:ontology-loading-probe", "@type": [ - "https://w3id.org/reachhaven/harbour/credentials/v1/LoadProbe", + "https://w3id.org/reachhaven/harbour/core/v1/LoadProbe", "https://w3id.org/reachhaven/harbour/core/v1/LoadProbe", "https://w3id.org/gaia-x/development#LoadProbe" ] From 705daf09ffeab4993781f1a401da373e5b40f6eb Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Mon, 16 Mar 2026 10:14:36 +0100 Subject: [PATCH 32/78] feat(signing): multi-key trust chain with derived did:ethr addresses Implement role-specific P-256 key pairs for the full trust chain so each party signs with its own cryptographic identity: - Add p256_public_key_to_eth_address() and p256_public_key_to_did_ethr() to harbour.keys using keccak-256 address derivation - Generate 5 fixture keys (trust-anchor, haven, company, employee, ascs) with deterministic did:ethr:0x14a34 addresses - Rewrite example_signer with RoleKeyring class that maps issuer/holder DIDs to the correct signing key - Rewrite verify_signed_examples with KeyResolver class that resolves JWT kid headers to role-specific public keys - Update all 19 example credentials and 4 DID documents with derived addresses - Add stable @id URIs to CompliantCredentialReference objects for SHACL graph deduplication - Fix Haven DID document service endpoint typing - Document sd_jwt_vp, delegation modules in Python/TypeScript API docs - Add DID Identity System page to mkdocs nav and home page links Signed-off-by: Carlo van Driesten --- docs/api/python/index.md | 16 ++ docs/api/typescript/index.md | 11 ++ docs/guide/delegated-signing.md | 18 +- docs/guide/evidence.md | 12 +- docs/index.md | 1 + docs/specs/delegation-challenge-encoding.md | 14 +- examples/README.md | 10 +- examples/credential-with-evidence.json | 12 +- examples/credential-with-nested-evidence.json | 6 +- .../did-ethr/harbour-signing-service.did.json | 45 +++-- .../did-ethr/harbour-trust-anchor.did.json | 16 +- ...6d7ea-27ef-416f-abf8-9cb634884e66.did.json | 14 +- ...e8400-e29b-41d4-a716-446655440000.did.json | 14 +- examples/gaiax/delegated-signing-receipt.json | 12 +- examples/gaiax/gx-legal-person.json | 2 +- examples/gaiax/gx-registration-number.json | 2 +- examples/gaiax/gx-terms-and-conditions.json | 2 +- .../legal-person-credential-embedded.json | 23 ++- examples/gaiax/legal-person-credential.json | 25 +-- examples/gaiax/natural-person-credential.json | 33 ++-- examples/gaiax/participant-vp.json | 33 ++-- examples/gaiax/trust-anchor-credential.json | 8 +- mkdocs.yml | 1 + src/python/credentials/example_signer.py | 166 +++++++++++++++--- .../credentials/verify_signed_examples.py | 94 ++++++++-- src/python/harbour/keys.py | 30 ++++ tests/fixtures/keys/README.md | 27 +++ tests/fixtures/keys/ascs.p256.json | 8 + tests/fixtures/keys/company.p256.json | 8 + tests/fixtures/keys/employee.p256.json | 8 + tests/fixtures/keys/haven.p256.json | 8 + tests/fixtures/keys/role-did-mapping.json | 27 +++ tests/fixtures/keys/trust-anchor.p256.json | 8 + tests/fixtures/sample-vc.json | 4 +- .../python/credentials/test_claim_mapping.py | 6 +- .../python/credentials/test_example_signer.py | 6 +- .../python/credentials/test_sign_examples.py | 26 ++- 37 files changed, 577 insertions(+), 179 deletions(-) create mode 100644 tests/fixtures/keys/README.md create mode 100644 tests/fixtures/keys/ascs.p256.json create mode 100644 tests/fixtures/keys/company.p256.json create mode 100644 tests/fixtures/keys/employee.p256.json create mode 100644 tests/fixtures/keys/haven.p256.json create mode 100644 tests/fixtures/keys/role-did-mapping.json create mode 100644 tests/fixtures/keys/trust-anchor.p256.json diff --git a/docs/api/python/index.md b/docs/api/python/index.md index 51e9599..10c6cd2 100644 --- a/docs/api/python/index.md +++ b/docs/api/python/index.md @@ -11,6 +11,8 @@ This section documents the Python API for Harbour Credentials. | `harbour.verifier` | JWT verification | | `harbour.sd_jwt` | SD-JWT selective disclosure | | `harbour.kb_jwt` | Key Binding JWT | +| `harbour.sd_jwt_vp` | SD-JWT Verifiable Presentations for privacy-preserving consent | +| `harbour.delegation` | Delegation challenge encoding and transaction data | | `harbour.x509` | X.509 certificates | ## Quick Import Reference @@ -51,6 +53,20 @@ from harbour.kb_jwt import ( verify_kb_jwt, ) +# SD-JWT VP +from harbour.sd_jwt_vp import ( + issue_sd_jwt_vp, + verify_sd_jwt_vp, +) + +# Delegation +from harbour.delegation import ( + TransactionData, + create_delegation_challenge, + parse_delegation_challenge, + verify_challenge, +) + # X.509 from harbour.x509 import ( generate_self_signed_cert, diff --git a/docs/api/typescript/index.md b/docs/api/typescript/index.md index 4e454eb..02c5007 100644 --- a/docs/api/typescript/index.md +++ b/docs/api/typescript/index.md @@ -35,6 +35,10 @@ import { // KB-JWT createKbJwt, verifyKbJwt, + + // SD-JWT VP + issueSdJwtVp, + verifySdJwtVp, // X.509 generateSelfSignedCert, @@ -69,6 +73,13 @@ interface KbJwtOptions { audience: string; issuedAt?: number; } + +interface SdJwtVpOptions { + disclosedClaims?: string[]; + evidence?: Record; + nonce?: string; + audience?: string; +} ``` ## Generated Documentation diff --git a/docs/guide/delegated-signing.md b/docs/guide/delegated-signing.md index 07facdd..a96f4b8 100644 --- a/docs/guide/delegated-signing.md +++ b/docs/guide/delegated-signing.md @@ -62,7 +62,7 @@ The user needs a Harbour credential (e.g., `NaturalPersonCredential`) issued as ```json { "type": ["VerifiableCredential", "harbour:NaturalPersonCredential"], - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "credentialSubject": { "id": "did:ethr:0x14a34:0x26e4...16c9", "type": "harbour:NaturalPerson", @@ -187,7 +187,7 @@ evidence = [{ "currency": "ENVITED" } }, - "delegatedTo": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697" + "delegatedTo": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202" }] # Create VP with selective disclosure (redact PII) @@ -197,7 +197,7 @@ sd_jwt_vp = issue_sd_jwt_vp( disclosures=["memberOf"], # Only disclose non-PII claims evidence=evidence, nonce="da9b1009", - audience="did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697" + audience="did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202" ) ``` @@ -221,10 +221,10 @@ const sdJwtVp = await issueSdJwtVp(sdJwtVc, holderPrivateKey, { currency: 'ENVITED' } }, - delegatedTo: 'did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697' + delegatedTo: 'did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202' }], nonce: 'da9b1009', - audience: 'did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697' + audience: 'did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202' }); ``` @@ -242,7 +242,7 @@ result = verify_sd_jwt_vp( issuer_public_key, # From credential issuer's DID holder_public_key, # From user's DID document expected_nonce="da9b1009", - expected_audience="did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697" + expected_audience="did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202" ) # Check transaction data matches original request @@ -263,11 +263,11 @@ After executing the transaction, the signing service issues a **receipt credenti ```json { "type": ["VerifiableCredential", "harbour:DelegatedSigningReceipt"], - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "evidence": [{ "type": "harbour:DelegatedSignatureEvidence", "verifiablePresentation": "", - "delegatedTo": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "delegatedTo": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "transaction_data": { "..." } }], "credentialStatus": [{ @@ -311,7 +311,7 @@ The `audience` field ensures the VP was created for a specific verifier: verify_sd_jwt_vp( vp, ..., - expected_audience="did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697" + expected_audience="did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202" ) ``` diff --git a/docs/guide/evidence.md b/docs/guide/evidence.md index 0e9eaa4..30a2198 100644 --- a/docs/guide/evidence.md +++ b/docs/guide/evidence.md @@ -29,17 +29,17 @@ The Harbour Signing Service is the **sole issuer** of all credentials. Evidence "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation", "harbour:VerifiablePresentation"], - "holder": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "verifiableCredential": [ { "@context": ["https://www.w3.org/ns/credentials/v2", "https://w3id.org/reachhaven/harbour/core/v1/"], "type": ["VerifiableCredential"], - "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "type": "harbour:LinkedCredentialService", - "didcore:serviceEndpoint": {"id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3"} + "didcore:serviceEndpoint": {"id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774"} } } ] @@ -59,7 +59,7 @@ Evidence on a **receipt credential** (SD-JWT-VC) that a signing service executed { "type": "harbour:DelegatedSignatureEvidence", "verifiablePresentation": "", - "delegatedTo": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "delegatedTo": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "transaction_data": { "type": "harbour.delegate:data.purchase", "credential_ids": ["harbour_natural_person"], @@ -154,7 +154,7 @@ When issuing a credential with evidence: credential = { "@context": [...], "type": ["VerifiableCredential", "harbour:NaturalPersonCredential"], - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "credentialSubject": {...}, "evidence": [ { diff --git a/docs/index.md b/docs/index.md index a735336..797bce4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -69,4 +69,5 @@ npm install @reachhaven/harbour-credentials - [Quick Start](getting-started/quickstart.md) — Get up and running - [CLI Reference](cli/index.md) — Command-line tools - [API Reference](api/python/index.md) — Python and TypeScript APIs +- [DID Identity System](did-identity-system.md) — `did:ethr` + P-256 + IdentityController architecture - [DID Method Evaluation](specs/did-method-evaluation.md) — `did:ethr` modeling notes and local reference specs diff --git a/docs/specs/delegation-challenge-encoding.md b/docs/specs/delegation-challenge-encoding.md index f57a275..1f1d930 100644 --- a/docs/specs/delegation-challenge-encoding.md +++ b/docs/specs/delegation-challenge-encoding.md @@ -194,17 +194,17 @@ The delegated consent is captured as `evidence` in a Verifiable Credential or di { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiableCredential"], - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "validFrom": "2026-02-24T12:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9" + "id": "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129" }, "evidence": [{ "type": ["CredentialEvidence"], "verifiablePresentation": { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", + "holder": "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129", "verifiableCredential": [ "" ], @@ -213,8 +213,8 @@ The delegated consent is captured as `evidence` in a Verifiable Credential or di "cryptosuite": "ecdsa-rdfc-2019", "proofPurpose": "authentication", "challenge": "da9b1009 HARBOUR_DELEGATE c3d4ba771c1103935ab4121874c4b3a78c8471719c80f60d59ca5811e232089b", - "domain": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", - "verificationMethod": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#controller", + "domain": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "verificationMethod": "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129#controller", "created": "2026-02-24T12:00:05Z", "proofValue": "z5vgFc..." } @@ -301,7 +301,7 @@ Per OID4VP Appendix B.3.3, the KB-JWT includes: ```json { "nonce": "n-0S6_WzA2Mj", - "aud": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "aud": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "iat": 1709838604, "sd_hash": "Dy-RYwZfaaoC3inJbLslgPvMp09bH-clYP_3qbRqtW4", "transaction_data_hashes": ["7W0LFUTpMvb6nJK7ngamNNY0zNvxqJ-2jNXTmLzhWQE"], @@ -615,7 +615,7 @@ OID4VP authorization request: ```json { "response_type": "vp_token", - "client_id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "client_id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "nonce": "da9b1009", "transaction_data": [{ "type": "harbour.delegate:data.purchase", diff --git a/examples/README.md b/examples/README.md index 1537c32..e73cf8f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -74,8 +74,8 @@ resolved DID document. | Actor | Role | Identity (`did:ethr`) | DID Document | |-------|------|-----------------------|--------------| -| **Harbour Trust Anchor** | Root of trust, authorizes orgs | `did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3` | [`harbour-trust-anchor.did.json`](did-ethr/harbour-trust-anchor.did.json) | -| **Harbour Signing Service** | Issues ALL credentials (`#controller`), signs delegated txns (`#delegate-1`) | `did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697` | [`harbour-signing-service.did.json`](did-ethr/harbour-signing-service.did.json) | +| **Harbour Trust Anchor** | Root of trust, authorizes orgs | `did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774` | [`harbour-trust-anchor.did.json`](did-ethr/harbour-trust-anchor.did.json) | +| **Harbour Signing Service** | Issues ALL credentials (`#controller`), signs delegated txns (`#delegate-1`) | `did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202` | [`harbour-signing-service.did.json`](did-ethr/harbour-signing-service.did.json) | | **Example Corporation GmbH** | Legal person (organization) | `did:ethr:0x14a34:0xf7ef...dab` | [`legal-person-0aa6d7ea-...did.json`](did-ethr/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json) | | **Alice Smith** | Natural person (employee) | `did:ethr:0x14a34:0x26e4...16c9` | [`natural-person-550e8400-...did.json`](did-ethr/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json) | | **ENVITED Marketplace** | Data marketplace (external) | `did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c` | — | @@ -288,10 +288,10 @@ sd_jwt_vp = issue_sd_jwt_vp( evidence=[{ "type": "DelegatedSignatureEvidence", "transaction_data": tx.to_dict(), - "delegatedTo": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "delegatedTo": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", }], nonce=tx.nonce, - audience="did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + audience="did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", ) ``` @@ -355,7 +355,7 @@ result = verify_sd_jwt_vp( issuer_public_key, holder_public_key, expected_nonce="da9b1009", - expected_audience="did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + expected_audience="did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", ) # Python — sign receipt credential diff --git a/examples/credential-with-evidence.json b/examples/credential-with-evidence.json index 1a72661..e6a029a 100644 --- a/examples/credential-with-evidence.json +++ b/examples/credential-with-evidence.json @@ -8,7 +8,7 @@ "harbour:VerifiableCredential" ], "id": "urn:uuid:11111111-1111-1111-1111-111111111111", - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "validFrom": "2024-01-15T00:00:00Z", "validUntil": "2025-01-15T00:00:00Z", "credentialSubject": { @@ -16,7 +16,7 @@ }, "credentialStatus": [ { - "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/a1b2c3d4e5f67890", + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/a1b2c3d4e5f67890", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -35,7 +35,7 @@ "VerifiablePresentation", "harbour:VerifiablePresentation" ], - "holder": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "verifiableCredential": [ { "@context": [ @@ -45,13 +45,13 @@ "type": [ "VerifiableCredential" ], - "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "type": "harbour:LinkedCredentialService", "didcore:serviceEndpoint": { - "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3" + "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" } } } diff --git a/examples/credential-with-nested-evidence.json b/examples/credential-with-nested-evidence.json index c4ff026..3dab435 100644 --- a/examples/credential-with-nested-evidence.json +++ b/examples/credential-with-nested-evidence.json @@ -8,7 +8,7 @@ "harbour:VerifiableCredential" ], "id": "urn:uuid:22222222-2222-2222-2222-222222222222", - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "validFrom": "2024-01-15T00:00:00Z", "validUntil": "2025-01-15T00:00:00Z", "credentialSubject": { @@ -16,7 +16,7 @@ }, "credentialStatus": [ { - "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/b2c3d4e5f6a78901", + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/b2c3d4e5f6a78901", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -45,7 +45,7 @@ "type": [ "VerifiableCredential" ], - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", diff --git a/examples/did-ethr/harbour-signing-service.did.json b/examples/did-ethr/harbour-signing-service.did.json index 9406d7f..34df4f9 100644 --- a/examples/did-ethr/harbour-signing-service.did.json +++ b/examples/did-ethr/harbour-signing-service.did.json @@ -1,6 +1,7 @@ { "@context": [ "https://www.w3.org/ns/did/v1", + "https://w3id.org/reachhaven/harbour/core/v1/", { "JsonWebKey": "https://w3id.org/security#JsonWebKey", "publicKeyJwk": { @@ -9,39 +10,57 @@ } } ], - "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "verificationMethod": [ { - "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#controller", + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#controller", "type": "JsonWebKey", - "controller": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "controller": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "publicKeyJwk": { "kty": "EC", "crv": "P-256", - "x": "W3OIQrEY5e5WOLMSqo82WIiKnNS3YZmCwazJ5jCReGk", - "y": "D562mZty35hWJ2V6rKQ5N5IJOKpZkVL52ucujzNcMI8" + "x": "5TDhagEwEJvWbr7gt91Pds6g74LVYlqunw6a863jAoQ", + "y": "uAaJmh4wdv9sAacVZyMDF55WscI8Gk9NwdVJzXjYek4" } }, { - "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-1", + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#delegate-1", "type": "JsonWebKey", - "controller": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "controller": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "publicKeyJwk": { "kty": "EC", "crv": "P-256", - "x": "aBzuAd1MlQAeJxCitiz-AqlMgsw1K1L_tuJ8u8hptxY", - "y": "ZRY5BsEos8y7QP4hKstg7uzFjduqq_zZlyFIES6Kagk" + "x": "5TDhagEwEJvWbr7gt91Pds6g74LVYlqunw6a863jAoQ", + "y": "uAaJmh4wdv9sAacVZyMDF55WscI8Gk9NwdVJzXjYek4" } } ], "authentication": [ - "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#controller", - "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-1" + "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#controller" ], "assertionMethod": [ - "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#controller" + "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#controller" ], "capabilityDelegation": [ - "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697#delegate-1" + "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#delegate-1" + ], + "service": [ + { + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#service-1", + "type": "harbour:TrustAnchorService", + "didcore:serviceEndpoint": { + "type": "sdo:Organization", + "name": "Haven Trust Anchor", + "url": "https://resolver.harbour.id/trust-anchors/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" + } + }, + { + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#service-2", + "type": "harbour:CRSetRevocationRegistryService", + "didcore:serviceEndpoint": { + "type": "harbour:CRSetServiceEndpoint", + "registryEndpoint": "https://resolver.harbour.id/crset/" + } + } ] } diff --git a/examples/did-ethr/harbour-trust-anchor.did.json b/examples/did-ethr/harbour-trust-anchor.did.json index 1e4e35c..a7429cf 100644 --- a/examples/did-ethr/harbour-trust-anchor.did.json +++ b/examples/did-ethr/harbour-trust-anchor.did.json @@ -10,29 +10,29 @@ } } ], - "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "verificationMethod": [ { - "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#controller", + "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774#controller", "type": "JsonWebKey", - "controller": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "controller": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "publicKeyJwk": { "kty": "EC", "crv": "P-256", - "x": "aBzuAd1MlQAeJxCitiz-AqlMgsw1K1L_tuJ8u8hptxY", - "y": "ZRY5BsEos8y7QP4hKstg7uzFjduqq_zZlyFIES6Kagk" + "x": "XHiins22glZnQ_fFRbt1biH1-0IUj2gpl0jbaUxe1Cc", + "y": "jW9z3Tz4x1ontNTdV3apbP3e8odM3ln9BHyu0zndRM8" } } ], "authentication": [ - "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#controller" + "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774#controller" ], "assertionMethod": [ - "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#controller" + "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774#controller" ], "service": [ { - "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3#service-1", + "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774#service-1", "type": "harbour:LinkedCredentialService", "serviceEndpoint": "https://reachhaven.com/.well-known/credentials/trust-anchor.jwt" } diff --git a/examples/did-ethr/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json b/examples/did-ethr/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json index 38fed22..60741e4 100644 --- a/examples/did-ethr/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json +++ b/examples/did-ethr/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json @@ -9,24 +9,24 @@ } } ], - "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", + "id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", "verificationMethod": [ { - "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab#controller", + "id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#controller", "type": "JsonWebKey", - "controller": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", + "controller": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", "publicKeyJwk": { "kty": "EC", "crv": "P-256", - "x": "ENro7uf0ePmiK3jdTo2YCdXLqW7z7xoP6qhhBou6gBL", - "y": "weN12rGDez3FP0HRkGbMEMN6YPf7rMBMcxmvIRbJboo" + "x": "cA5C-HS35A0oj56Udl_HS7nvtAwpWTf3fAGXJFYm3Qo", + "y": "Y16LRNZm58cqhsPb0XsWqtixDYDcKUgdGsiiici7NNo" } } ], "authentication": [ - "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab#controller" + "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#controller" ], "assertionMethod": [ - "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab#controller" + "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#controller" ] } diff --git a/examples/did-ethr/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json b/examples/did-ethr/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json index 11c618d..64d64b3 100644 --- a/examples/did-ethr/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json +++ b/examples/did-ethr/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json @@ -9,24 +9,24 @@ } } ], - "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", + "id": "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129", "verificationMethod": [ { - "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#controller", + "id": "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129#controller", "type": "JsonWebKey", - "controller": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", + "controller": "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129", "publicKeyJwk": { "kty": "EC", "crv": "P-256", - "x": "6dkU6ZMFK79WwicwJ5rbxE13zSukBY2OoEiVUEjqMEc", - "y": "RnHznyVlrPSMT7irDs15D9wxgMojiSDAQpfFhqTkLRY" + "x": "tKjHMweiTEmNdgXye76UgmVSMA7mg5lsdZeav2alTyY", + "y": "lI83pcZ5BeUmOdrmLgx0KJ0DTbpcTC320WoryselneU" } } ], "authentication": [ - "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#controller" + "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129#controller" ], "assertionMethod": [ - "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9#controller" + "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129#controller" ] } diff --git a/examples/gaiax/delegated-signing-receipt.json b/examples/gaiax/delegated-signing-receipt.json index 3d6d523..a16b92a 100644 --- a/examples/gaiax/delegated-signing-receipt.json +++ b/examples/gaiax/delegated-signing-receipt.json @@ -9,7 +9,7 @@ "harbour:DelegatedSigningReceipt" ], "id": "urn:uuid:f7e8d9c0-b1a2-3456-7890-abcdef012345", - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "validFrom": "2025-06-25T10:00:00Z", "credentialSubject": { "id": "urn:uuid:receipt-b7c8d9e0-f1a2-3456-789a-bcdef0123456", @@ -19,7 +19,7 @@ }, "credentialStatus": [ { - "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/f7e8d9c0b1a23456", + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/f7e8d9c0b1a23456", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -49,17 +49,17 @@ "type": [ "VerifiableCredential" ], - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "validFrom": "2024-01-15T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", + "id": "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129", "type": "harbour.gx:NaturalPerson", - "memberOf": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" + "memberOf": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93" } } ] }, - "delegatedTo": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "delegatedTo": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "transaction_data": { "type": "harbour.delegate:data.purchase", "credential_ids": [ diff --git a/examples/gaiax/gx-legal-person.json b/examples/gaiax/gx-legal-person.json index a4c030d..10786d1 100644 --- a/examples/gaiax/gx-legal-person.json +++ b/examples/gaiax/gx-legal-person.json @@ -11,7 +11,7 @@ "VerifiableCredential" ], "id": "urn:uuid:f1a2b3c4-d5e6-7890-abcd-ef1234567890", - "issuer": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", + "issuer": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", "validFrom": "2024-01-15T00:00:00Z", "validUntil": "2025-01-15T00:00:00Z", "credentialSubject": { diff --git a/examples/gaiax/gx-registration-number.json b/examples/gaiax/gx-registration-number.json index 466c055..6a4932d 100644 --- a/examples/gaiax/gx-registration-number.json +++ b/examples/gaiax/gx-registration-number.json @@ -9,7 +9,7 @@ "id": "urn:uuid:c3d4e5f6-a7b8-9012-cdef-123456789012", "name": "VAT ID", "description": "Value Added Tax Identifier", - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "validFrom": "2024-01-15T00:00:00Z", "validUntil": "2024-07-15T00:00:00Z", "credentialSubject": { diff --git a/examples/gaiax/gx-terms-and-conditions.json b/examples/gaiax/gx-terms-and-conditions.json index f6e1e56..79cb60b 100644 --- a/examples/gaiax/gx-terms-and-conditions.json +++ b/examples/gaiax/gx-terms-and-conditions.json @@ -7,7 +7,7 @@ "VerifiableCredential" ], "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f12345678901", - "issuer": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", + "issuer": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", "validFrom": "2024-01-15T00:00:00Z", "validUntil": "2025-01-15T00:00:00Z", "credentialSubject": { diff --git a/examples/gaiax/legal-person-credential-embedded.json b/examples/gaiax/legal-person-credential-embedded.json index 8d6db7d..4af4d68 100644 --- a/examples/gaiax/legal-person-credential-embedded.json +++ b/examples/gaiax/legal-person-credential-embedded.json @@ -10,7 +10,7 @@ "harbour.gx:LegalPersonCredential" ], "id": "urn:uuid:e1e2e3e4-e5e6-e7e8-e9e0-e1e2e3e4e5e6", - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "validFrom": "2024-01-16T00:00:00Z", "validUntil": "2025-01-16T00:00:00Z", "credentialSubject": { @@ -20,19 +20,22 @@ "type": "harbour.gx:CompliantCredentialReference", "harbour.gx:credentialType": "gx:LegalPerson", "harbour.gx:digestSRI": "sha256-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", - "harbour.gx:embeddedCredential": "{\"@context\":[\"https://www.w3.org/ns/credentials/v2\",\"https://w3id.org/gaia-x/development#\",{\"vcard\":\"http://www.w3.org/2006/vcard/ns#\",\"schema\":\"http://schema.org/\"}],\"type\":[\"VerifiableCredential\"],\"id\":\"urn:uuid:e2e3e4e5-e6e7-e8e9-e0e1-e2e3e4e5e6e7\",\"issuer\":\"did:ethr:0x14a34:0xa2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1\",\"validFrom\":\"2024-01-15T00:00:00Z\",\"validUntil\":\"2025-01-15T00:00:00Z\",\"credentialSubject\":{\"type\":\"gx:LegalPerson\",\"id\":\"urn:uuid:e2e3e4e5-e6e7-e8e9-e0e1-e2e3e4e5e6e7#cs\",\"schema:name\":\"Example Corporation GmbH\",\"gx:registrationNumber\":{\"id\":\"urn:uuid:e3e4e5e6-e7e8-e9e0-e1e2-e3e4e5e6e7e8#cs\"},\"gx:headquartersAddress\":{\"type\":\"gx:Address\",\"gx:countryCode\":\"DE\",\"vcard:street-address\":\"Musterstra\\u00dfe 42\",\"vcard:locality\":\"M\\u00fcnchen\",\"vcard:postal-code\":\"80331\"},\"gx:legalAddress\":{\"type\":\"gx:Address\",\"gx:countryCode\":\"DE\",\"vcard:street-address\":\"Musterstra\\u00dfe 42\",\"vcard:locality\":\"M\\u00fcnchen\",\"vcard:postal-code\":\"80331\"}}}" + "harbour.gx:embeddedCredential": "{\"@context\":[\"https://www.w3.org/ns/credentials/v2\",\"https://w3id.org/gaia-x/development#\",{\"vcard\":\"http://www.w3.org/2006/vcard/ns#\",\"schema\":\"http://schema.org/\"}],\"type\":[\"VerifiableCredential\"],\"id\":\"urn:uuid:e2e3e4e5-e6e7-e8e9-e0e1-e2e3e4e5e6e7\",\"issuer\":\"did:ethr:0x14a34:0xa2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1\",\"validFrom\":\"2024-01-15T00:00:00Z\",\"validUntil\":\"2025-01-15T00:00:00Z\",\"credentialSubject\":{\"type\":\"gx:LegalPerson\",\"id\":\"urn:uuid:e2e3e4e5-e6e7-e8e9-e0e1-e2e3e4e5e6e7#cs\",\"schema:name\":\"Example Corporation GmbH\",\"gx:registrationNumber\":{\"id\":\"urn:uuid:e3e4e5e6-e7e8-e9e0-e1e2-e3e4e5e6e7e8#cs\"},\"gx:headquartersAddress\":{\"type\":\"gx:Address\",\"gx:countryCode\":\"DE\",\"vcard:street-address\":\"Musterstra\\u00dfe 42\",\"vcard:locality\":\"M\\u00fcnchen\",\"vcard:postal-code\":\"80331\"},\"gx:legalAddress\":{\"type\":\"gx:Address\",\"gx:countryCode\":\"DE\",\"vcard:street-address\":\"Musterstra\\u00dfe 42\",\"vcard:locality\":\"M\\u00fcnchen\",\"vcard:postal-code\":\"80331\"}}}", + "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantLegalPersonVC" }, "harbour.gx:compliantRegistrationVC": { "type": "harbour.gx:CompliantCredentialReference", "harbour.gx:credentialType": "gx:VatID", "harbour.gx:digestSRI": "sha256-c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4", - "harbour.gx:embeddedCredential": "{\"@context\":[\"https://www.w3.org/ns/credentials/v2\",\"https://w3id.org/gaia-x/development#\"],\"type\":[\"VerifiableCredential\"],\"id\":\"urn:uuid:e3e4e5e6-e7e8-e9e0-e1e2-e3e4e5e6e7e8\",\"name\":\"VAT ID\",\"description\":\"Value Added Tax Identifier\",\"issuer\":\"did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697\",\"validFrom\":\"2024-01-15T00:00:00Z\",\"validUntil\":\"2024-07-15T00:00:00Z\",\"credentialSubject\":{\"type\":\"gx:VatID\",\"id\":\"urn:uuid:e3e4e5e6-e7e8-e9e0-e1e2-e3e4e5e6e7e8#cs\",\"gx:vatID\":\"DE123456789\",\"gx:countryCode\":\"DE\"},\"evidence\":{\"gx:evidenceOf\":\"gx:VatID\",\"gx:evidenceURL\":\"http://ec.europa.eu/taxation_customs/vies/services/checkVatService\",\"gx:executionDate\":\"2024-01-15T00:00:00Z\"}}" + "harbour.gx:embeddedCredential": "{\"@context\":[\"https://www.w3.org/ns/credentials/v2\",\"https://w3id.org/gaia-x/development#\"],\"type\":[\"VerifiableCredential\"],\"id\":\"urn:uuid:e3e4e5e6-e7e8-e9e0-e1e2-e3e4e5e6e7e8\",\"name\":\"VAT ID\",\"description\":\"Value Added Tax Identifier\",\"issuer\":\"did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202\",\"validFrom\":\"2024-01-15T00:00:00Z\",\"validUntil\":\"2024-07-15T00:00:00Z\",\"credentialSubject\":{\"type\":\"gx:VatID\",\"id\":\"urn:uuid:e3e4e5e6-e7e8-e9e0-e1e2-e3e4e5e6e7e8#cs\",\"gx:vatID\":\"DE123456789\",\"gx:countryCode\":\"DE\"},\"evidence\":{\"gx:evidenceOf\":\"gx:VatID\",\"gx:evidenceURL\":\"http://ec.europa.eu/taxation_customs/vies/services/checkVatService\",\"gx:executionDate\":\"2024-01-15T00:00:00Z\"}}", + "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantRegistrationVC" }, "harbour.gx:compliantTermsVC": { "type": "harbour.gx:CompliantCredentialReference", "harbour.gx:credentialType": "gx:Issuer", "harbour.gx:digestSRI": "sha256-e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6", - "harbour.gx:embeddedCredential": "{\"@context\":[\"https://www.w3.org/ns/credentials/v2\",\"https://w3id.org/gaia-x/development#\"],\"type\":[\"VerifiableCredential\"],\"id\":\"urn:uuid:e4e5e6e7-e8e9-e0e1-e2e3-e4e5e6e7e8e9\",\"issuer\":\"did:ethr:0x14a34:0xa2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1\",\"validFrom\":\"2024-01-15T00:00:00Z\",\"validUntil\":\"2025-01-15T00:00:00Z\",\"credentialSubject\":{\"type\":\"gx:Issuer\",\"id\":\"urn:uuid:e4e5e6e7-e8e9-e0e1-e2e3-e4e5e6e7e8e9#cs\",\"gx:gaiaxTermsAndConditions\":\"4bd7554097444c960292b4726c2efa1373485e8a5565d94d41195214c5e0ceb3\"}}" + "harbour.gx:embeddedCredential": "{\"@context\":[\"https://www.w3.org/ns/credentials/v2\",\"https://w3id.org/gaia-x/development#\"],\"type\":[\"VerifiableCredential\"],\"id\":\"urn:uuid:e4e5e6e7-e8e9-e0e1-e2e3-e4e5e6e7e8e9\",\"issuer\":\"did:ethr:0x14a34:0xa2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1\",\"validFrom\":\"2024-01-15T00:00:00Z\",\"validUntil\":\"2025-01-15T00:00:00Z\",\"credentialSubject\":{\"type\":\"gx:Issuer\",\"id\":\"urn:uuid:e4e5e6e7-e8e9-e0e1-e2e3-e4e5e6e7e8e9#cs\",\"gx:gaiaxTermsAndConditions\":\"4bd7554097444c960292b4726c2efa1373485e8a5565d94d41195214c5e0ceb3\"}}", + "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantTermsVC" }, "harbour.gx:labelLevel": "SC", "harbour.gx:engineVersion": "2.11.0", @@ -43,7 +46,7 @@ }, "credentialStatus": [ { - "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/e1e2e3e4e5e6e7e8", + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/e1e2e3e4e5e6e7e8", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -62,7 +65,7 @@ "VerifiablePresentation", "harbour:VerifiablePresentation" ], - "holder": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "verifiableCredential": [ { "@context": [ @@ -72,12 +75,14 @@ "type": [ "VerifiableCredential" ], - "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "type": "harbour:LinkedCredentialService", - "didcore:serviceEndpoint": {"id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3"} + "didcore:serviceEndpoint": { + "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" + } } } ] diff --git a/examples/gaiax/legal-person-credential.json b/examples/gaiax/legal-person-credential.json index 23cf932..74cb72f 100644 --- a/examples/gaiax/legal-person-credential.json +++ b/examples/gaiax/legal-person-credential.json @@ -10,26 +10,29 @@ "harbour.gx:LegalPersonCredential" ], "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "validFrom": "2024-01-16T00:00:00Z", "validUntil": "2025-01-16T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", + "id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", "type": "harbour.gx:LegalPerson", "harbour.gx:compliantLegalPersonVC": { "type": "harbour.gx:CompliantCredentialReference", "harbour.gx:credentialType": "gx:LegalPerson", - "harbour.gx:digestSRI": "sha256-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + "harbour.gx:digestSRI": "sha256-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", + "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantLegalPersonVC" }, "harbour.gx:compliantRegistrationVC": { "type": "harbour.gx:CompliantCredentialReference", "harbour.gx:credentialType": "gx:VatID", - "harbour.gx:digestSRI": "sha256-c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4" + "harbour.gx:digestSRI": "sha256-c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4", + "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantRegistrationVC" }, "harbour.gx:compliantTermsVC": { "type": "harbour.gx:CompliantCredentialReference", "harbour.gx:credentialType": "gx:Issuer", - "harbour.gx:digestSRI": "sha256-e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6" + "harbour.gx:digestSRI": "sha256-e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6", + "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantTermsVC" }, "harbour.gx:labelLevel": "SC", "harbour.gx:engineVersion": "2.11.0", @@ -40,7 +43,7 @@ }, "credentialStatus": [ { - "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/a1b2c3d4e5f67890", + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/a1b2c3d4e5f67890", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -59,7 +62,7 @@ "VerifiablePresentation", "harbour:VerifiablePresentation" ], - "holder": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "verifiableCredential": [ { "@context": [ @@ -69,12 +72,14 @@ "type": [ "VerifiableCredential" ], - "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "type": "harbour:LinkedCredentialService", - "didcore:serviceEndpoint": {"id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3"} + "didcore:serviceEndpoint": { + "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" + } } } ] diff --git a/examples/gaiax/natural-person-credential.json b/examples/gaiax/natural-person-credential.json index c06c922..02f30bd 100644 --- a/examples/gaiax/natural-person-credential.json +++ b/examples/gaiax/natural-person-credential.json @@ -10,16 +10,16 @@ "harbour.gx:NaturalPersonCredential" ], "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f23456789012", - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "validFrom": "2024-01-15T00:00:00Z", "validUntil": "2025-01-15T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0x26e47c8d7dd2b2e3406de73446ce3dcbb40916c9", + "id": "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129", "type": "harbour.gx:NaturalPerson", "givenName": "Alice", "familyName": "Smith", "email": "alice.smith@example.com", - "memberOf": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", + "memberOf": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", "address": { "type": "gx:Address", "countryCode": "DE", @@ -29,7 +29,7 @@ }, "credentialStatus": [ { - "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/b2c3d4e5f6a78901", + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/b2c3d4e5f6a78901", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -51,7 +51,7 @@ "VerifiablePresentation", "harbour:VerifiablePresentation" ], - "holder": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", + "holder": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", "verifiableCredential": [ { "@context": [ @@ -65,26 +65,29 @@ "harbour.gx:LegalPersonCredential" ], "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567899", - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "validFrom": "2024-01-16T00:00:00Z", "validUntil": "2025-01-16T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xb1b2b3b4b5b6b7b8b9b0b1b2b3b4b5b6b7b8b9b0", + "id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", "type": "harbour.gx:LegalPerson", "harbour.gx:compliantLegalPersonVC": { "type": "harbour.gx:CompliantCredentialReference", "harbour.gx:credentialType": "gx:LegalPerson", - "harbour.gx:digestSRI": "sha256-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + "harbour.gx:digestSRI": "sha256-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", + "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantLegalPersonVC" }, "harbour.gx:compliantRegistrationVC": { "type": "harbour.gx:CompliantCredentialReference", "harbour.gx:credentialType": "gx:VatID", - "harbour.gx:digestSRI": "sha256-c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4" + "harbour.gx:digestSRI": "sha256-c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4", + "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantRegistrationVC" }, "harbour.gx:compliantTermsVC": { "type": "harbour.gx:CompliantCredentialReference", "harbour.gx:credentialType": "gx:Issuer", - "harbour.gx:digestSRI": "sha256-e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6" + "harbour.gx:digestSRI": "sha256-e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6", + "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantTermsVC" }, "harbour.gx:labelLevel": "SC", "harbour.gx:engineVersion": "2.11.0", @@ -93,7 +96,7 @@ }, "credentialStatus": [ { - "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/a1b2c3d4e5f67899", + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/a1b2c3d4e5f67899", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -113,7 +116,7 @@ "VerifiablePresentation", "harbour:VerifiablePresentation" ], - "holder": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "verifiableCredential": [ { "@context": [ @@ -123,13 +126,13 @@ "type": [ "VerifiableCredential" ], - "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "type": "harbour:LinkedCredentialService", "didcore:serviceEndpoint": { - "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3" + "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" } } } diff --git a/examples/gaiax/participant-vp.json b/examples/gaiax/participant-vp.json index face334..4f3baa9 100644 --- a/examples/gaiax/participant-vp.json +++ b/examples/gaiax/participant-vp.json @@ -37,15 +37,15 @@ "gx:headquartersAddress": { "type": "gx:Address", "gx:countryCode": "DE", - "vcard:street-address": "Musterstra\u00dfe 42", - "vcard:locality": "M\u00fcnchen", + "vcard:street-address": "Musterstraße 42", + "vcard:locality": "München", "vcard:postal-code": "80331" }, "gx:legalAddress": { "type": "gx:Address", "gx:countryCode": "DE", - "vcard:street-address": "Musterstra\u00dfe 42", - "vcard:locality": "M\u00fcnchen", + "vcard:street-address": "Musterstraße 42", + "vcard:locality": "München", "vcard:postal-code": "80331" } } @@ -61,7 +61,7 @@ "id": "urn:uuid:c3d4e5f6-a7b8-9012-cdef-123456789019", "name": "VAT ID", "description": "Value Added Tax Identifier", - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "validFrom": "2024-01-15T00:00:00Z", "validUntil": "2024-07-15T00:00:00Z", "credentialSubject": { @@ -106,7 +106,7 @@ "harbour.gx:LegalPersonCredential" ], "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "issuer": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "validFrom": "2024-01-16T00:00:00Z", "validUntil": "2025-01-16T00:00:00Z", "credentialSubject": { @@ -115,17 +115,20 @@ "harbour.gx:compliantLegalPersonVC": { "type": "harbour.gx:CompliantCredentialReference", "harbour.gx:credentialType": "gx:LegalPerson", - "harbour.gx:digestSRI": "sha256-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + "harbour.gx:digestSRI": "sha256-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", + "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantLegalPersonVC" }, "harbour.gx:compliantRegistrationVC": { "type": "harbour.gx:CompliantCredentialReference", "harbour.gx:credentialType": "gx:VatID", - "harbour.gx:digestSRI": "sha256-c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4" + "harbour.gx:digestSRI": "sha256-c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4", + "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantRegistrationVC" }, "harbour.gx:compliantTermsVC": { "type": "harbour.gx:CompliantCredentialReference", "harbour.gx:credentialType": "gx:Issuer", - "harbour.gx:digestSRI": "sha256-e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6" + "harbour.gx:digestSRI": "sha256-e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6", + "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantTermsVC" }, "harbour.gx:labelLevel": "SC", "harbour.gx:engineVersion": "2.11.0", @@ -136,7 +139,7 @@ }, "credentialStatus": [ { - "id": "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697:services:revocation-registry/a1b2c3d4e5f67890", + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/a1b2c3d4e5f67890", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -155,7 +158,7 @@ "VerifiablePresentation", "harbour:VerifiablePresentation" ], - "holder": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "verifiableCredential": [ { "@context": [ @@ -165,12 +168,14 @@ "type": [ "VerifiableCredential" ], - "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "type": "harbour:LinkedCredentialService", - "didcore:serviceEndpoint": {"id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3"} + "didcore:serviceEndpoint": { + "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" + } } } ] diff --git a/examples/gaiax/trust-anchor-credential.json b/examples/gaiax/trust-anchor-credential.json index 5e06624..234eccf 100644 --- a/examples/gaiax/trust-anchor-credential.json +++ b/examples/gaiax/trust-anchor-credential.json @@ -8,16 +8,16 @@ "harbour:VerifiableCredential" ], "id": "urn:uuid:c4d5e6f7-a8b9-0123-cdef-456789abcdef", - "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "type": "harbour:LinkedCredentialService", - "didcore:serviceEndpoint": {"id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3"} + "didcore:serviceEndpoint": {"id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774"} }, "credentialStatus": [ { - "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3:services:revocation-registry/c4d5e6f7a8b90123", + "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774:services:revocation-registry/c4d5e6f7a8b90123", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } diff --git a/mkdocs.yml b/mkdocs.yml index feb9256..c25b729 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -68,6 +68,7 @@ nav: - Delegated Signing: guide/delegated-signing.md - Evidence Types: guide/evidence.md - Specifications: + - DID Identity System: did-identity-system.md - Delegation Challenge Encoding: specs/delegation-challenge-encoding.md - DID Method Evaluation: specs/did-method-evaluation.md - Reference Specs: specs/references/README.md diff --git a/src/python/credentials/example_signer.py b/src/python/credentials/example_signer.py index f36b97c..55a70a6 100644 --- a/src/python/credentials/example_signer.py +++ b/src/python/credentials/example_signer.py @@ -5,6 +5,14 @@ When given a directory, also processes gaiax/ subdirectory if present, outputting signed artifacts to each subdirectory's own signed/ folder. +Each role in the trust chain uses a **separate P-256 key** so that the +signed artifacts cryptographically demonstrate who signed what: + + - Trust Anchor key → self-signed VC, evidence VPs authorising orgs + - Haven key → all outer credentials (issuer) + - Company key → evidence VPs authorising employees + - Employee key → consent VPs for delegated signing + Output per credential: - .jwt — VC-JOSE-COSE compact JWS (wire format) - .decoded.json — Decoded JWT header + payload @@ -28,6 +36,7 @@ from cryptography.hazmat.primitives.asymmetric.ec import ( SECP256R1, + EllipticCurvePrivateKey, EllipticCurvePrivateNumbers, EllipticCurvePublicNumbers, ) @@ -58,8 +67,71 @@ def _decode_jwt(token: str) -> dict: return {"header": header, "payload": payload} +def _load_jwk_private_key(jwk_path: Path) -> EllipticCurvePrivateKey: + """Load a P-256 private key from a JWK file.""" + jwk = json.loads(jwk_path.read_text()) + x = int.from_bytes(_b64url_decode(jwk["x"]), "big") + y = int.from_bytes(_b64url_decode(jwk["y"]), "big") + d = int.from_bytes(_b64url_decode(jwk["d"]), "big") + pub_numbers = EllipticCurvePublicNumbers(x, y, SECP256R1()) + priv_numbers = EllipticCurvePrivateNumbers(d, pub_numbers) + return priv_numbers.private_key() + + +class RoleKeyring: + """Manages per-role P-256 keys and DID-to-key resolution. + + Loads role-specific key files from ``tests/fixtures/keys/`` and builds + a mapping from did:ethr addresses to (private_key, kid) pairs. + """ + + ROLE_FILES = { + "trust-anchor": "trust-anchor.p256.json", + "haven": "haven.p256.json", + "company": "company.p256.json", + "employee": "employee.p256.json", + "ascs": "ascs.p256.json", + } + + def __init__(self, keys_dir: Path): + from harbour.keys import p256_public_key_to_did_ethr + + self._keys: dict[str, tuple[EllipticCurvePrivateKey, str]] = {} + self._role_dids: dict[str, str] = {} + + for role, filename in self.ROLE_FILES.items(): + key_path = keys_dir / filename + if not key_path.exists(): + continue + priv = _load_jwk_private_key(key_path) + did = p256_public_key_to_did_ethr(priv.public_key()) + kid = f"{did}#controller" + self._keys[did] = (priv, kid) + self._role_dids[role] = did + + if self._keys: + print(f" Loaded {len(self._keys)} role keys:") + for role, did in self._role_dids.items(): + print(f" {role}: {did}") + + @property + def role_dids(self) -> dict[str, str]: + return dict(self._role_dids) + + def resolve(self, did: str) -> tuple[EllipticCurvePrivateKey, str] | None: + """Resolve a DID to its (private_key, kid) pair.""" + return self._keys.get(did) + + def get_role_key(self, role: str) -> tuple[EllipticCurvePrivateKey, str] | None: + """Get key pair for a named role.""" + did = self._role_dids.get(role) + if did: + return self._keys[did] + return None + + def load_test_p256_keypair(fixtures_dir: Path | None = None): - """Load the committed P-256 test keypair.""" + """Load the committed P-256 test keypair (legacy single-key mode).""" if fixtures_dir is None: repo_root = _find_repo_root() fixtures_dir = ( @@ -72,21 +144,39 @@ def load_test_p256_keypair(fixtures_dir: Path | None = None): jwk_path = keys_dir / "test-keypair-p256.json" else: jwk_path = fixtures_dir / "test-keypair-p256.json" - jwk = json.loads(jwk_path.read_text()) - x = int.from_bytes(_b64url_decode(jwk["x"]), "big") - y = int.from_bytes(_b64url_decode(jwk["y"]), "big") - d = int.from_bytes(_b64url_decode(jwk["d"]), "big") - pub_numbers = EllipticCurvePublicNumbers(x, y, SECP256R1()) - priv_numbers = EllipticCurvePrivateNumbers(d, pub_numbers) - private_key = priv_numbers.private_key() - return private_key, private_key.public_key() + priv = _load_jwk_private_key(jwk_path) + return priv, priv.public_key() -def sign_evidence_vp(vp: dict, private_key: PrivateKey, kid: str) -> str: +def load_role_keyring(fixtures_dir: Path | None = None) -> RoleKeyring | None: + """Load the multi-role keyring if role key files exist.""" + if fixtures_dir is None: + repo_root = _find_repo_root() + fixtures_dir = ( + repo_root / "submodules" / "harbour-credentials" / "tests" / "fixtures" + ) + if not fixtures_dir.is_dir(): + fixtures_dir = repo_root / "tests" / "fixtures" + keys_dir = fixtures_dir / "keys" + if not keys_dir.is_dir(): + return None + probe = keys_dir / "haven.p256.json" + if not probe.exists(): + return None + return RoleKeyring(keys_dir) + + +def sign_evidence_vp( + vp: dict, + private_key: PrivateKey, + kid: str, + keyring: RoleKeyring | None = None, +) -> str: """Sign an evidence VP and its inner VCs as VC-JOSE-COSE JWTs. - Takes the expanded VP object, signs each inner VC, replaces them with - JWT strings, then signs the VP envelope. + When *keyring* is provided, each inner VC is signed with its own + issuer's key (looked up by ``issuer`` DID). The VP envelope is + signed with *private_key* / *kid* (the holder's key). """ clean_vp = { "@context": vp.get("@context", ["https://www.w3.org/ns/credentials/v2"]), @@ -96,15 +186,19 @@ def sign_evidence_vp(vp: dict, private_key: PrivateKey, kid: str) -> str: if "holder" in vp: clean_vp["holder"] = vp["holder"] - # Sign inner VCs inner_vcs = vp.get("verifiableCredential", []) inner_jwts = [] for vc in inner_vcs: if isinstance(vc, dict): - inner_jwt = sign_vc_jose(vc, private_key, kid=kid) + inner_issuer = vc.get("issuer", "") + inner_key, inner_kid = private_key, kid + if keyring: + resolved = keyring.resolve(inner_issuer) + if resolved: + inner_key, inner_kid = resolved + inner_jwt = sign_vc_jose(vc, inner_key, kid=inner_kid) inner_jwts.append(inner_jwt) else: - # Already a JWT string inner_jwts.append(vc) if inner_jwts: clean_vp["verifiableCredential"] = inner_jwts @@ -135,16 +229,32 @@ def decode_evidence_vp(vp_jwt: str) -> dict: def process_example( - example_path: Path, private_key: PrivateKey, kid: str, output_dir: Path + example_path: Path, + private_key: PrivateKey, + kid: str, + output_dir: Path, + keyring: RoleKeyring | None = None, ) -> Path: """Process a single example credential. Reads the expanded example, signs evidence and outer VC, writes all artifacts to output_dir. Never modifies the source file. + + When *keyring* is provided, the outer VC is signed with the key + matching the credential's ``issuer`` DID, and each evidence VP is + signed with the key matching the VP's ``holder`` DID. """ vc = json.loads(example_path.read_text()) stem = example_path.stem + # Determine outer credential signing key + outer_key, outer_kid = private_key, kid + if keyring: + issuer_did = vc.get("issuer", "") + resolved = keyring.resolve(issuer_did) + if resolved: + outer_key, outer_kid = resolved + evidence_vp_jwt = None # Sign evidence VPs if present (work on a copy for outer signing) @@ -153,13 +263,20 @@ def process_example( for ev in vc_for_signing["evidence"]: vp_obj = ev.get("verifiablePresentation") if isinstance(vp_obj, dict): - # Expanded VP — sign it - evidence_vp_jwt = sign_evidence_vp(vp_obj, private_key, kid) - # Replace with JWT string for outer VC signing + # Determine evidence VP signing key (holder's key) + ev_holder = vp_obj.get("holder", "") + ev_key, ev_kid = private_key, kid + if keyring: + resolved = keyring.resolve(ev_holder) + if resolved: + ev_key, ev_kid = resolved + evidence_vp_jwt = sign_evidence_vp( + vp_obj, ev_key, ev_kid, keyring=keyring + ) ev["verifiablePresentation"] = evidence_vp_jwt # Sign the outer credential - vc_jwt = sign_vc_jose(vc_for_signing, private_key, kid=kid) + vc_jwt = sign_vc_jose(vc_for_signing, outer_key, kid=outer_kid) # Write outputs output_dir.mkdir(parents=True, exist_ok=True) @@ -230,13 +347,16 @@ def main(): args = parser.parse_args() - # Load key + # Load key(s) + keyring = None if args.key: from harbour._crypto import load_private_key as _load_private_key private_key, _ = _load_private_key(args.key) public_key = private_key.public_key() else: + # Try multi-role keyring first, fall back to single test key + keyring = load_role_keyring() private_key, public_key = load_test_p256_keypair() kid = p256_public_key_to_did_key(public_key) @@ -282,7 +402,9 @@ def main(): output_dir = Path(args.output_dir) else: output_dir = path.parent / "signed" - jwt_path = process_example(path, private_key, kid_vm, output_dir) + jwt_path = process_example( + path, private_key, kid_vm, output_dir, keyring=keyring + ) output_dirs_used.add(output_dir) rel = path.parent.name prefix = f"{rel}/" if rel != "examples" else "" diff --git a/src/python/credentials/verify_signed_examples.py b/src/python/credentials/verify_signed_examples.py index 5c298e5..b3ebeee 100644 --- a/src/python/credentials/verify_signed_examples.py +++ b/src/python/credentials/verify_signed_examples.py @@ -2,18 +2,39 @@ This script validates the ignored ``examples/signed/`` and ``examples/gaiax/signed/`` output folders using the real Harbour verification -functions. It is intended for end-to-end storyline runs driven by ``make``. +functions. When multi-role keys are available, each JWT is verified with +the key that matches its ``kid`` header — proving that different roles +really did sign different artifacts. """ from __future__ import annotations +import base64 +import json from dataclasses import dataclass from pathlib import Path -from credentials.example_signer import load_test_p256_keypair +from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey + +from credentials.example_signer import ( + RoleKeyring, + load_role_keyring, + load_test_p256_keypair, +) from harbour.verifier import verify_vc_jose, verify_vp_jose +def _b64url_decode(s: str) -> bytes: + return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) + + +def _jwt_kid(token: str) -> str | None: + """Extract the kid from a JWT header without full verification.""" + parts = token.split(".") + header = json.loads(_b64url_decode(parts[0])) + return header.get("kid") + + @dataclass class VerificationCounts: """Summary counters for verified example artifacts.""" @@ -23,6 +44,40 @@ class VerificationCounts: nested_credentials: int = 0 +class KeyResolver: + """Resolve a JWT kid to its public key, using keyring or fallback.""" + + def __init__( + self, + keyring: RoleKeyring | None, + fallback_public_key: EllipticCurvePublicKey, + ): + self._keyring = keyring + self._fallback = fallback_public_key + self._kid_cache: dict[str, EllipticCurvePublicKey] = {} + + if keyring: + for role, did in keyring.role_dids.items(): + kid = f"{did}#controller" + pair = keyring.resolve(did) + if pair: + priv, _ = pair + self._kid_cache[kid] = priv.public_key() + + def resolve(self, kid: str | None) -> EllipticCurvePublicKey: + if kid and kid in self._kid_cache: + return self._kid_cache[kid] + return self._fallback + + def role_for_kid(self, kid: str | None) -> str: + if not kid or not self._keyring: + return "fallback" + for role, did in self._keyring.role_dids.items(): + if kid.startswith(did): + return role + return "fallback" + + def _find_repo_root() -> Path: current = Path(__file__).resolve().parent while current != current.parent: @@ -56,7 +111,7 @@ def _assert_has_type(payload: dict, expected_type: str, source: Path) -> None: ) -def verify_signed_dir(signed_dir: Path, public_key) -> VerificationCounts: +def verify_signed_dir(signed_dir: Path, resolver: KeyResolver) -> VerificationCounts: counts = VerificationCounts() signed_credentials = _iter_signed_credentials(signed_dir) if not signed_credentials: @@ -64,18 +119,26 @@ def verify_signed_dir(signed_dir: Path, public_key) -> VerificationCounts: for jwt_path in signed_credentials: vc_jwt = jwt_path.read_text(encoding="utf-8").strip() - vc_payload = verify_vc_jose(vc_jwt, public_key) + kid = _jwt_kid(vc_jwt) + pub = resolver.resolve(kid) + role = resolver.role_for_kid(kid) + vc_payload = verify_vc_jose(vc_jwt, pub) _assert_has_type(vc_payload, "VerifiableCredential", jwt_path) counts.credentials += 1 + print(f" ✓ {jwt_path.name} (signed by: {role})") evidence_jwt_path = jwt_path.with_name(f"{jwt_path.stem}.evidence-vp.jwt") if not evidence_jwt_path.exists(): continue vp_jwt = evidence_jwt_path.read_text(encoding="utf-8").strip() - vp_payload = verify_vp_jose(vp_jwt, public_key) + vp_kid = _jwt_kid(vp_jwt) + vp_pub = resolver.resolve(vp_kid) + vp_role = resolver.role_for_kid(vp_kid) + vp_payload = verify_vp_jose(vp_jwt, vp_pub) _assert_has_type(vp_payload, "VerifiablePresentation", evidence_jwt_path) counts.evidence_presentations += 1 + print(f" ✓ {evidence_jwt_path.name} (signed by: {vp_role})") embedded_vps = [ evidence.get("verifiablePresentation") @@ -89,18 +152,25 @@ def verify_signed_dir(signed_dir: Path, public_key) -> VerificationCounts: for inner in vp_payload.get("verifiableCredential", []): if isinstance(inner, str) and inner.count(".") == 2: - inner_payload = verify_vc_jose(inner, public_key) + inner_kid = _jwt_kid(inner) + inner_pub = resolver.resolve(inner_kid) + inner_role = resolver.role_for_kid(inner_kid) + inner_payload = verify_vc_jose(inner, inner_pub) _assert_has_type( inner_payload, "VerifiableCredential", evidence_jwt_path ) counts.nested_credentials += 1 + print(f" ✓ inner VC (signed by: {inner_role})") return counts def main() -> None: repo_root = _find_repo_root() - _, public_key = load_test_p256_keypair() + keyring = load_role_keyring() + _, fallback_pub = load_test_p256_keypair() + resolver = KeyResolver(keyring, fallback_pub) + signed_dirs = _discover_signed_dirs(repo_root) if not signed_dirs: raise RuntimeError( @@ -109,18 +179,14 @@ def main() -> None: total = VerificationCounts() for signed_dir in signed_dirs: - counts = verify_signed_dir(signed_dir, public_key) + print(f" Verifying {signed_dir}/") + counts = verify_signed_dir(signed_dir, resolver) total.credentials += counts.credentials total.evidence_presentations += counts.evidence_presentations total.nested_credentials += counts.nested_credentials - print( - f"Verified {counts.credentials} credential JWT(s), " - f"{counts.evidence_presentations} evidence VP JWT(s), and " - f"{counts.nested_credentials} nested VC JWT(s) in {signed_dir}" - ) print( - "Done: " + "\nDone: " f"{total.credentials} credential JWT(s), " f"{total.evidence_presentations} evidence VP JWT(s), " f"{total.nested_credentials} nested VC JWT(s) verified" diff --git a/src/python/harbour/keys.py b/src/python/harbour/keys.py index 6aea48f..58a06ee 100644 --- a/src/python/harbour/keys.py +++ b/src/python/harbour/keys.py @@ -134,6 +134,36 @@ def p256_public_key_to_did_key(public_key: EllipticCurvePublicKey) -> str: return f"did:key:{mb}" +def p256_public_key_to_eth_address(public_key: EllipticCurvePublicKey) -> str: + """Derive an Ethereum-style address from a P-256 public key. + + Uses keccak256(uncompressed_point[1:])[-20:], mirroring how Ethereum + derives addresses from secp256k1 keys. In production, did:ethr + addresses are keyless (IdentityController + CREATE2), but this + deterministic derivation is useful for self-contained test fixtures. + """ + from Crypto.Hash import keccak + + uncompressed = public_key.public_bytes( + Encoding.X962, PublicFormat.UncompressedPoint + ) + digest = keccak.new(digest_bits=256, data=uncompressed[1:]).digest() + return "0x" + digest[-20:].hex() + + +def p256_public_key_to_did_ethr( + public_key: EllipticCurvePublicKey, + chain_id: str = "0x14a34", +) -> str: + """Derive a did:ethr identifier from a P-256 public key. + + Combines ``p256_public_key_to_eth_address`` with the did:ethr format. + Default chain_id ``0x14a34`` is Base testnet (84532). + """ + addr = p256_public_key_to_eth_address(public_key) + return f"did:ethr:{chain_id}:{addr}" + + # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- diff --git a/tests/fixtures/keys/README.md b/tests/fixtures/keys/README.md new file mode 100644 index 0000000..b7736b9 --- /dev/null +++ b/tests/fixtures/keys/README.md @@ -0,0 +1,27 @@ +# Test Key Fixtures + +P-256 key pairs for each role in the trust chain. +did:ethr addresses are derived from P-256 public keys via +`keccak256(uncompressed_point[1:])[-20:]` for self-contained test fixtures. + +**DO NOT use these keys in production.** + +| Role | File | did:ethr | did:key | +|------|------|---------|---------| +| trust-anchor | `trust-anchor.p256.json` | `did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774` | `did:key:zDnaeotFceWszHurnw1CQuhqVsBCsX6s...` | +| haven | `haven.p256.json` | `did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202` | `did:key:zDnaefrde2MxCJfVoE1Z6RW6Zk6S91ot...` | +| company | `company.p256.json` | `did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93` | `did:key:zDnaeXyPKbKzPn6vR73hsPF8T12hexnm...` | +| employee | `employee.p256.json` | `did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129` | `did:key:zDnaeupW53s139booGLf5QepJbWnvc9e...` | +| ascs | `ascs.p256.json` | `did:ethr:0x14a34:0x26bac51329c3c13230a77e8524bfbb62e1a8e2d3` | `did:key:zDnaebg1BPCQvqzPWHD53VtVvbFjKwWV...` | + +## Chain ID + +`0x14a34` = Base testnet (84532 decimal) + +## Derivation + +```python +from harbour.keys import p256_public_key_to_did_ethr + +did = p256_public_key_to_did_ethr(public_key) # did:ethr:0x14a34:0x +``` diff --git a/tests/fixtures/keys/ascs.p256.json b/tests/fixtures/keys/ascs.p256.json new file mode 100644 index 0000000..1ffdd2e --- /dev/null +++ b/tests/fixtures/keys/ascs.p256.json @@ -0,0 +1,8 @@ +{ + "kty": "EC", + "crv": "P-256", + "x": "pwkZnCghcLvWGSk5EEk6FVeba744bLuXgooJUhP6evY", + "y": "4YaNHSmXnL-aP9kyr5djfMNk5fWvhtv4QHj4YbpbzOI", + "d": "uw_jcT2iUexhxDcRPMoIBs51CAiCHeiL6VkkjihXzjI", + "_comment": "TEST ONLY \u2014 ASCS Issuer \u2014 issues SimpulseID domain credentials" +} diff --git a/tests/fixtures/keys/company.p256.json b/tests/fixtures/keys/company.p256.json new file mode 100644 index 0000000..823902f --- /dev/null +++ b/tests/fixtures/keys/company.p256.json @@ -0,0 +1,8 @@ +{ + "kty": "EC", + "crv": "P-256", + "x": "cA5C-HS35A0oj56Udl_HS7nvtAwpWTf3fAGXJFYm3Qo", + "y": "Y16LRNZm58cqhsPb0XsWqtixDYDcKUgdGsiiici7NNo", + "d": "dm2_eVfXmAwW4sVg16jJ4i_M2XxLcNs0tgtmuslhcXI", + "_comment": "TEST ONLY \u2014 Company (Legal Person) \u2014 organization subject, approves employee issuance" +} diff --git a/tests/fixtures/keys/employee.p256.json b/tests/fixtures/keys/employee.p256.json new file mode 100644 index 0000000..60c9ea4 --- /dev/null +++ b/tests/fixtures/keys/employee.p256.json @@ -0,0 +1,8 @@ +{ + "kty": "EC", + "crv": "P-256", + "x": "tKjHMweiTEmNdgXye76UgmVSMA7mg5lsdZeav2alTyY", + "y": "lI83pcZ5BeUmOdrmLgx0KJ0DTbpcTC320WoryselneU", + "d": "9lapE-EQItu9EzUm8bnai1-nrYZ91IaM_DcU7JrD1KE", + "_comment": "TEST ONLY \u2014 Employee (Natural Person) \u2014 individual subject, signs consent VPs" +} diff --git a/tests/fixtures/keys/haven.p256.json b/tests/fixtures/keys/haven.p256.json new file mode 100644 index 0000000..88d059b --- /dev/null +++ b/tests/fixtures/keys/haven.p256.json @@ -0,0 +1,8 @@ +{ + "kty": "EC", + "crv": "P-256", + "x": "5TDhagEwEJvWbr7gt91Pds6g74LVYlqunw6a863jAoQ", + "y": "uAaJmh4wdv9sAacVZyMDF55WscI8Gk9NwdVJzXjYek4", + "d": "Lh_iWmDLKDnznSCIToKUSEQfyegJCc53ulS5z1vrua0", + "_comment": "TEST ONLY \u2014 Haven Signing Service \u2014 sole issuer of all harbour credentials" +} diff --git a/tests/fixtures/keys/role-did-mapping.json b/tests/fixtures/keys/role-did-mapping.json new file mode 100644 index 0000000..bfffaed --- /dev/null +++ b/tests/fixtures/keys/role-did-mapping.json @@ -0,0 +1,27 @@ +{ + "trust-anchor": { + "did_ethr": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", + "did_key": "did:key:zDnaeotFceWszHurnw1CQuhqVsBCsX6scX7npWxanRSguYifG", + "eth_addr": "0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" + }, + "haven": { + "did_ethr": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "did_key": "did:key:zDnaefrde2MxCJfVoE1Z6RW6Zk6S91ot2w2x1c9Xwm5WiBMo9", + "eth_addr": "0x31f1ca3dc5da9f83f360d805662d11a418950202" + }, + "company": { + "did_ethr": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", + "did_key": "did:key:zDnaeXyPKbKzPn6vR73hsPF8T12hexnmhfAjv3bzvSmihDy8M", + "eth_addr": "0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93" + }, + "employee": { + "did_ethr": "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129", + "did_key": "did:key:zDnaeupW53s139booGLf5QepJbWnvc9eSfMWyTmbSF2rF8A5b", + "eth_addr": "0x272c04206c826047add586cbf7f4ffc4386da129" + }, + "ascs": { + "did_ethr": "did:ethr:0x14a34:0x26bac51329c3c13230a77e8524bfbb62e1a8e2d3", + "did_key": "did:key:zDnaebg1BPCQvqzPWHD53VtVvbFjKwWVMStEHxhK8zPuDNiWR", + "eth_addr": "0x26bac51329c3c13230a77e8524bfbb62e1a8e2d3" + } +} diff --git a/tests/fixtures/keys/trust-anchor.p256.json b/tests/fixtures/keys/trust-anchor.p256.json new file mode 100644 index 0000000..17ba08f --- /dev/null +++ b/tests/fixtures/keys/trust-anchor.p256.json @@ -0,0 +1,8 @@ +{ + "kty": "EC", + "crv": "P-256", + "x": "XHiins22glZnQ_fFRbt1biH1-0IUj2gpl0jbaUxe1Cc", + "y": "jW9z3Tz4x1ontNTdV3apbP3e8odM3ln9BHyu0zndRM8", + "d": "HIVaaLBVhqeLr91yhVE9_7cKyPyUJTedFSecAs0p-kM", + "_comment": "TEST ONLY \u2014 Trust Anchor \u2014 root of trust, self-signed LinkedCredentialService" +} diff --git a/tests/fixtures/sample-vc.json b/tests/fixtures/sample-vc.json index e4fcf7a..7520603 100644 --- a/tests/fixtures/sample-vc.json +++ b/tests/fixtures/sample-vc.json @@ -5,10 +5,10 @@ ], "type": ["VerifiableCredential"], "id": "urn:uuid:576fbefb-35e8-4b71-bb1a-53d1803c86de", - "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", + "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2025-08-06T10:15:22Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", + "id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", "type": "harbour:LegalPerson", "name": "Example Corporation GmbH" } diff --git a/tests/python/credentials/test_claim_mapping.py b/tests/python/credentials/test_claim_mapping.py index 3785f55..fc22b6c 100644 --- a/tests/python/credentials/test_claim_mapping.py +++ b/tests/python/credentials/test_claim_mapping.py @@ -40,11 +40,11 @@ def test_vc_to_claims(self): assert ( claims["iss"] - == "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697" + == "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202" ) assert ( claims["sub"] - == "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" + == "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93" ) assert claims["labelLevel"] == "SC" assert "engineVersion" in claims @@ -92,7 +92,7 @@ def test_vc_to_claims(self): assert "givenName" in disclosable assert ( claims["memberOf"] - == "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab" + == "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93" ) def test_has_credential_status(self): diff --git a/tests/python/credentials/test_example_signer.py b/tests/python/credentials/test_example_signer.py index 0c9cdf3..24904a2 100644 --- a/tests/python/credentials/test_example_signer.py +++ b/tests/python/credentials/test_example_signer.py @@ -59,7 +59,7 @@ def test_sign_evidence_vp(self, signing_key): vp = { "@context": ["https://www.w3.org/ns/credentials/v2"], "type": ["VerifiablePresentation"], - "holder": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", + "holder": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", "verifiableCredential": [ { "@context": ["https://www.w3.org/ns/credentials/v2"], @@ -67,7 +67,7 @@ def test_sign_evidence_vp(self, signing_key): "issuer": "did:ethr:0x14a34:0x7863e20b04934e8a439e196beac92f3cc3b3676c", "validFrom": "2024-01-10T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", + "id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", "type": "gx:LegalPerson", }, } @@ -193,7 +193,7 @@ def test_process_delegated_signing_receipt(self, signing_key, tmp_path): assert evidence["transaction_data"]["type"] == "harbour.delegate:data.purchase" assert ( evidence["delegatedTo"] - == "did:ethr:0x14a34:0x9c2f52ea812629d0d35b2786ae26633d03a8c697" + == "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202" ) # Evidence VP should be a signed JWT diff --git a/tests/python/credentials/test_sign_examples.py b/tests/python/credentials/test_sign_examples.py index a79fc1b..3009f4d 100644 --- a/tests/python/credentials/test_sign_examples.py +++ b/tests/python/credentials/test_sign_examples.py @@ -43,8 +43,28 @@ def test_tamper_detection_jose( verify_vc_jose(tampered_token, p256_public_key) -def test_verify_signed_jwt(signed_jwt, p256_public_key): - """Verify a pre-generated signed JWT from examples/signed/.""" - result = verify_vc_jose(signed_jwt, p256_public_key) +def test_verify_signed_jwt(signed_jwt): + """Verify a pre-generated signed JWT from examples/signed/. + + Uses the role keyring to resolve the correct public key from the + JWT's kid header, proving that each credential was signed by the + expected role. + """ + import base64 + import json + + from credentials.example_signer import load_role_keyring, load_test_p256_keypair + from credentials.verify_signed_examples import KeyResolver + + keyring = load_role_keyring() + _, fallback_pub = load_test_p256_keypair() + resolver = KeyResolver(keyring, fallback_pub) + + parts = signed_jwt.split(".") + header = json.loads(base64.urlsafe_b64decode(parts[0] + "==")) + kid = header.get("kid") + pub = resolver.resolve(kid) + + result = verify_vc_jose(signed_jwt, pub) assert "type" in result assert "VerifiableCredential" in result["type"] From 5308bd9a90c16816b1e967f01cf5443c2519cf74 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Mon, 16 Mar 2026 10:30:27 +0100 Subject: [PATCH 33/78] test(shacl): add validation failure test suite Add pytest-native SHACL negative test suite that programmatically mutates valid credentials and asserts pyshacl catches specific violations: - 3 positive baseline tests (valid credentials must pass) - 14 missing mandatory field tests (sh:MinCountConstraintComponent) - 5 wrong type tests (sh:Class/Datatype/MaxCount violations) - 3 closed shape tests (sh:ClosedConstraintComponent) - 3 cardinality tests (sh:MaxCountConstraintComponent) Each test starts from a known-good example, applies a single mutation, and verifies the expected SHACL constraint component and property path. Structured ShaclViolation dataclass provides human-readable debug output. Signed-off-by: Carlo van Driesten --- .../python/credentials/test_shacl_failures.py | 687 ++++++++++++++++++ 1 file changed, 687 insertions(+) create mode 100644 tests/python/credentials/test_shacl_failures.py diff --git a/tests/python/credentials/test_shacl_failures.py b/tests/python/credentials/test_shacl_failures.py new file mode 100644 index 0000000..41110f8 --- /dev/null +++ b/tests/python/credentials/test_shacl_failures.py @@ -0,0 +1,687 @@ +"""SHACL validation failure tests — verify shapes catch invalid credentials. + +This test suite programmatically mutates valid credential examples and +asserts that SHACL validation catches each specific error. Every test +starts from a known-good credential, applies a single mutation, and +checks that pyshacl reports the expected violation. + +The test output is designed for debuggability: +- Each test ID clearly describes the mutation (e.g., "LegalPerson-missing-issuer") +- Assertion messages show the full SHACL results text on unexpected outcomes +- The ``ShaclViolation`` helper formats violations in a human-readable way + +Run with:: + + pytest tests/python/credentials/test_shacl_failures.py -v + +To debug a single test:: + + pytest tests/python/credentials/test_shacl_failures.py -v -k "missing_issuer" + +Requires generated artifacts (``make generate``) and pyshacl. +""" + +import copy +import json +import sys +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +import pytest +import rdflib +from rdflib import RDF, Namespace + +# --------------------------------------------------------------------------- +# Repository paths +# --------------------------------------------------------------------------- + +_REPO_ROOT = Path(__file__).resolve().parent +while _REPO_ROOT.name != "harbour-credentials" and _REPO_ROOT != _REPO_ROOT.parent: + _REPO_ROOT = _REPO_ROOT.parent + +_OMB = _REPO_ROOT / "submodules" / "ontology-management-base" + +_CORE_SHACL = ( + _REPO_ROOT / "artifacts/harbour-core-credential/harbour-core-credential.shacl.ttl" +) +_GX_SHACL = ( + _REPO_ROOT / "artifacts/harbour-gx-credential/harbour-gx-credential.shacl.ttl" +) +_EXAMPLES = _REPO_ROOT / "examples" + +# SHACL namespace for result graph queries +SH = Namespace("http://www.w3.org/ns/shacl#") +CRED = Namespace("https://www.w3.org/2018/credentials#") +HARBOUR = Namespace("https://w3id.org/reachhaven/harbour/core/v1/") +HARBOUR_GX = Namespace("https://w3id.org/reachhaven/harbour/gx/v1/") + +# --------------------------------------------------------------------------- +# Skip if artifacts haven't been generated +# --------------------------------------------------------------------------- + +_skip_no_artifacts = pytest.mark.skipif( + not _GX_SHACL.exists(), + reason="Generated artifacts not found — run 'make generate' first", +) + +_skip_no_omb = pytest.mark.skipif( + not (_OMB / "src" / "tools" / "utils" / "context_resolver.py").exists(), + reason="ontology-management-base submodule not initialised", +) + + +# --------------------------------------------------------------------------- +# Structured violation helper +# --------------------------------------------------------------------------- + + +@dataclass +class ShaclViolation: + """Human-readable representation of a single SHACL validation result.""" + + focus_node: str + result_path: Optional[str] + constraint: str + severity: str + message: str + + def __str__(self) -> str: + path_str = f" path={self.result_path}" if self.result_path else "" + return ( + f"[{self.severity}]{path_str} constraint={self.constraint} — {self.message}" + ) + + +def _extract_violations(results_graph: rdflib.Graph) -> list[ShaclViolation]: + """Extract structured violations from a pyshacl results graph.""" + violations = [] + for result in results_graph.subjects(RDF.type, SH.ValidationResult): + paths = list(results_graph.objects(result, SH.resultPath)) + severities = list(results_graph.objects(result, SH.resultSeverity)) + components = list(results_graph.objects(result, SH.sourceConstraintComponent)) + messages = list(results_graph.objects(result, SH.resultMessage)) + focus_nodes = list(results_graph.objects(result, SH.focusNode)) + + violations.append( + ShaclViolation( + focus_node=str(focus_nodes[0]) if focus_nodes else "?", + result_path=str(paths[0]) if paths else None, + constraint=str(components[0]).split("#")[-1] if components else "?", + severity=str(severities[0]).split("#")[-1] if severities else "?", + message=str(messages[0]) if messages else "(no message)", + ) + ) + return violations + + +def _format_violations(violations: list[ShaclViolation]) -> str: + """Format violations for assertion messages.""" + if not violations: + return "(no violations)" + return "\n".join(f" • {v}" for v in violations) + + +# --------------------------------------------------------------------------- +# JSON-LD → RDF parsing with local context resolution +# --------------------------------------------------------------------------- + + +def _build_context_url_map() -> dict[str, Path]: + """Build URL → local file mapping for offline JSON-LD parsing.""" + sys.path.insert(0, str(_OMB)) + from src.tools.utils.context_resolver import discover_context_files + + artifact_dirs = [ + _REPO_ROOT / "artifacts/harbour-core-credential", + _REPO_ROOT / "artifacts/harbour-gx-credential", + _REPO_ROOT / "artifacts/harbour-core-delegation", + _OMB / "artifacts/gx", + ] + url_map = discover_context_files(artifact_dirs) + + # Add OMB import contexts (W3C credentials/v2, status, did, schema.org) + catalog_path = _OMB / "imports" / "catalog-v001.xml" + if catalog_path.exists(): + tree = ET.parse(catalog_path) + cat_ns = {"c": "urn:oasis:names:tc:entity:xmlns:xml:catalog"} + for uri_elem in tree.getroot().findall(".//c:uri", cat_ns): + name = uri_elem.get("name", "") + val = uri_elem.get("uri", "") + if val.endswith(".context.jsonld"): + abs_path = (_OMB / "imports" / val).resolve() + if abs_path.exists(): + url_map[name] = abs_path + + return url_map + + +# --------------------------------------------------------------------------- +# Session-scoped fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def context_url_map(): + """URL → local file map for offline JSON-LD context resolution.""" + return _build_context_url_map() + + +@pytest.fixture(scope="session") +def shacl_graph(): + """Combined SHACL shapes graph (core + gx).""" + g = rdflib.Graph() + g.parse(str(_GX_SHACL), format="turtle") + return g + + +# --------------------------------------------------------------------------- +# Validation helper +# --------------------------------------------------------------------------- + + +def _validate( + credential: dict, + url_map: dict[str, Path], + shapes: rdflib.Graph, +) -> tuple[bool, list[ShaclViolation], str]: + """Validate a credential dict against SHACL shapes. + + Returns: + (conforms, violations, results_text) + + The results_text is the full pyshacl report — useful for debugging + when a test fails unexpectedly. + """ + sys.path.insert(0, str(_OMB)) + import pyshacl + from src.tools.utils.context_resolver import inline_jsonld_with_local_contexts + + inlined_json = inline_jsonld_with_local_contexts(credential, url_map) + dg = rdflib.Graph() + dg.parse(data=inlined_json, format="json-ld") + + conforms, results_graph, results_text = pyshacl.validate( + data_graph=dg, + shacl_graph=shapes, + inference="none", + ) + violations = _extract_violations(results_graph) + return conforms, violations, results_text + + +# --------------------------------------------------------------------------- +# Credential mutation helpers +# --------------------------------------------------------------------------- + + +def _load_example(name: str) -> dict: + """Load an example credential by filename.""" + for subdir in ["gaiax", ""]: + path = _EXAMPLES / subdir / name if subdir else _EXAMPLES / name + if path.exists(): + return json.loads(path.read_text()) + raise FileNotFoundError(f"Example not found: {name}") + + +def _remove_field(data: dict, *keys: str) -> dict: + """Return a copy with a nested field removed. + + Example: _remove_field(data, "credentialSubject", "type") + removes data["credentialSubject"]["type"]. + """ + data = copy.deepcopy(data) + target = data + for key in keys[:-1]: + target = target[key] + del target[keys[-1]] + return data + + +def _set_field(data: dict, value, *keys: str) -> dict: + """Return a copy with a nested field set to a new value.""" + data = copy.deepcopy(data) + target = data + for key in keys[:-1]: + target = target[key] + target[keys[-1]] = value + return data + + +def _add_field(data: dict, key: str, value) -> dict: + """Return a copy with an extra top-level field added.""" + data = copy.deepcopy(data) + data[key] = value + return data + + +# ═══════════════════════════════════════════════════════════════════════════ +# Test classes +# ═══════════════════════════════════════════════════════════════════════════ + + +@_skip_no_artifacts +@_skip_no_omb +class TestPositiveBaseline: + """Sanity check — valid examples must pass SHACL validation. + + If these fail, the shapes or examples are broken, not the test suite. + """ + + @pytest.mark.parametrize( + "example_file", + [ + "legal-person-credential.json", + "natural-person-credential.json", + "trust-anchor-credential.json", + ], + ids=[ + "LegalPersonCredential-valid", + "NaturalPersonCredential-valid", + "TrustAnchorCredential-valid", + ], + ) + def test_valid_credential_conforms( + self, example_file, context_url_map, shacl_graph + ): + """A valid credential must pass SHACL validation with zero violations.""" + cred = _load_example(example_file) + conforms, violations, text = _validate(cred, context_url_map, shacl_graph) + assert conforms, ( + f"Valid {example_file} should conform but got {len(violations)} " + f"violation(s):\n{_format_violations(violations)}\n\n" + f"Full SHACL report:\n{text}" + ) + + +# --------------------------------------------------------------------------- +# C1: Missing mandatory fields (sh:MinCountConstraintComponent) +# --------------------------------------------------------------------------- + +# Each tuple: (example_file, field_path, expected_shacl_path, test_id) +# field_path is the key chain to remove, e.g. ("issuer",) or ("credentialSubject", "givenName") +_MISSING_FIELD_CASES = [ + # --- LegalPersonCredential envelope --- + ( + "legal-person-credential.json", + ("issuer",), + str(CRED.issuer), + "LegalPersonCredential-missing-issuer", + ), + ( + "legal-person-credential.json", + ("validFrom",), + str(CRED.validFrom), + "LegalPersonCredential-missing-validFrom", + ), + ( + "legal-person-credential.json", + ("credentialStatus",), + str(CRED.credentialStatus), + "LegalPersonCredential-missing-credentialStatus", + ), + ( + "legal-person-credential.json", + ("evidence",), + str(CRED.evidence), + "LegalPersonCredential-missing-evidence", + ), + # --- LegalPerson subject (compliance slots) --- + # Note: JSON keys use "harbour.gx:" prefix (compact IRI from JSON-LD context) + ( + "legal-person-credential.json", + ("credentialSubject", "harbour.gx:compliantLegalPersonVC"), + str(HARBOUR_GX.compliantLegalPersonVC), + "LegalPerson-missing-compliantLegalPersonVC", + ), + ( + "legal-person-credential.json", + ("credentialSubject", "harbour.gx:compliantRegistrationVC"), + str(HARBOUR_GX.compliantRegistrationVC), + "LegalPerson-missing-compliantRegistrationVC", + ), + ( + "legal-person-credential.json", + ("credentialSubject", "harbour.gx:compliantTermsVC"), + str(HARBOUR_GX.compliantTermsVC), + "LegalPerson-missing-compliantTermsVC", + ), + ( + "legal-person-credential.json", + ("credentialSubject", "harbour.gx:labelLevel"), + str(HARBOUR_GX.labelLevel), + "LegalPerson-missing-labelLevel", + ), + ( + "legal-person-credential.json", + ("credentialSubject", "harbour.gx:engineVersion"), + str(HARBOUR_GX.engineVersion), + "LegalPerson-missing-engineVersion", + ), + ( + "legal-person-credential.json", + ("credentialSubject", "harbour.gx:rulesVersion"), + str(HARBOUR_GX.rulesVersion), + "LegalPerson-missing-rulesVersion", + ), + # --- NaturalPersonCredential envelope --- + ( + "natural-person-credential.json", + ("issuer",), + str(CRED.issuer), + "NaturalPersonCredential-missing-issuer", + ), + ( + "natural-person-credential.json", + ("validFrom",), + str(CRED.validFrom), + "NaturalPersonCredential-missing-validFrom", + ), + ( + "natural-person-credential.json", + ("credentialStatus",), + str(CRED.credentialStatus), + "NaturalPersonCredential-missing-credentialStatus", + ), + ( + "natural-person-credential.json", + ("evidence",), + str(CRED.evidence), + "NaturalPersonCredential-missing-evidence", + ), + # Note: NaturalPerson subject has NO minCount constraints on givenName/familyName + # (they are optional per SHACL). The shape IS sh:closed, so wrong property names + # are caught by ClosedConstraintComponent tests instead. +] + + +@_skip_no_artifacts +@_skip_no_omb +class TestMissingMandatoryFields: + """Removing a required field must trigger a sh:MinCountConstraintComponent violation. + + Each test removes exactly one mandatory field from a valid credential + and asserts that SHACL reports a violation on the correct property path. + + To debug a failure, look at: + 1. The test ID — tells you which credential and which field + 2. The assertion message — shows the full SHACL report + 3. The ``expected_path`` — the IRI of the property that should be flagged + """ + + @pytest.mark.parametrize( + "example_file, field_path, expected_path, test_id", + _MISSING_FIELD_CASES, + ids=[c[3] for c in _MISSING_FIELD_CASES], + ) + def test_missing_field_detected( + self, + example_file, + field_path, + expected_path, + test_id, + context_url_map, + shacl_graph, + ): + cred = _load_example(example_file) + mutated = _remove_field(cred, *field_path) + + conforms, violations, text = _validate(mutated, context_url_map, shacl_graph) + + # Must not conform + assert not conforms, ( + f"[{test_id}] Credential should FAIL without " + f"'{'.'.join(field_path)}' but SHACL said it conforms.\n" + f"This means the shape does not enforce this field as mandatory." + ) + + # Must have a MinCount violation on the expected path + min_count_on_path = [ + v + for v in violations + if v.constraint == "MinCountConstraintComponent" + and v.result_path == expected_path + ] + assert min_count_on_path, ( + f"[{test_id}] Expected MinCountConstraintComponent on " + f"path <{expected_path}> but got:\n" + f"{_format_violations(violations)}\n\n" + f"Full SHACL report:\n{text}" + ) + + +# --------------------------------------------------------------------------- +# C2: Wrong type violations (sh:ClassConstraintComponent / sh:DatatypeConstraintComponent) +# --------------------------------------------------------------------------- + +# Each tuple: (example_file, mutation_fn, expected_constraint, test_id) +_WRONG_TYPE_CASES = [ + # evidence should be an array of objects — a string gets parsed but fails sh:class + ( + "legal-person-credential.json", + lambda d: _set_field(d, "not-an-object", "evidence"), + "ClassConstraintComponent", + "LegalPersonCredential-evidence-wrong-type", + ), + # validFrom must be xsd:dateTime, not an object + ( + "legal-person-credential.json", + lambda d: _set_field(d, {"broken": True}, "validFrom"), + "DatatypeConstraintComponent", + "LegalPersonCredential-validFrom-wrong-type", + ), + # credentialStatus must be CRSetEntry objects — a string fails sh:class + ( + "legal-person-credential.json", + lambda d: _set_field(d, "revoked", "credentialStatus"), + "ClassConstraintComponent", + "LegalPersonCredential-credentialStatus-wrong-type", + ), + # compliantLegalPersonVC must be a CompliantCredentialReference, not a string + ( + "legal-person-credential.json", + lambda d: _set_field( + d, "just-a-string", "credentialSubject", "harbour.gx:compliantLegalPersonVC" + ), + "ClassConstraintComponent", + "LegalPerson-compliantLegalPersonVC-wrong-type", + ), + # labelLevel must be a string, not an array (sh:maxCount 1) + ( + "legal-person-credential.json", + lambda d: _set_field( + d, ["SC", "L1"], "credentialSubject", "harbour.gx:labelLevel" + ), + "MaxCountConstraintComponent", + "LegalPerson-labelLevel-wrong-type", + ), +] + + +@_skip_no_artifacts +@_skip_no_omb +class TestWrongTypes: + """Setting a field to the wrong type must trigger a type-related violation. + + Each test replaces a field value with an incompatible type and asserts + that SHACL catches the mismatch. The expected constraint component + varies — e.g., putting a string where an object is expected may trigger + MinCount (the string doesn't create a valid node) or Class violations. + + Debugging: check the constraint in the assertion message to understand + which SHACL rule caught the error. + """ + + @pytest.mark.parametrize( + "example_file, mutate_fn, expected_constraint, test_id", + _WRONG_TYPE_CASES, + ids=[c[3] for c in _WRONG_TYPE_CASES], + ) + def test_wrong_type_detected( + self, + example_file, + mutate_fn, + expected_constraint, + test_id, + context_url_map, + shacl_graph, + ): + cred = _load_example(example_file) + mutated = mutate_fn(cred) + + conforms, violations, text = _validate(mutated, context_url_map, shacl_graph) + + assert not conforms, ( + f"[{test_id}] Credential with wrong type should FAIL " + f"but SHACL said it conforms." + ) + + matching = [v for v in violations if v.constraint == expected_constraint] + assert matching, ( + f"[{test_id}] Expected {expected_constraint} violation but got:\n" + f"{_format_violations(violations)}\n\n" + f"Full SHACL report:\n{text}" + ) + + +# --------------------------------------------------------------------------- +# C3: Closed shape violations (sh:ClosedConstraintComponent) +# --------------------------------------------------------------------------- + +_CLOSED_SHAPE_CASES = [ + ( + "legal-person-credential.json", + "unknownField", + "surprise!", + "LegalPersonCredential-unexpected-property", + ), + ( + "natural-person-credential.json", + "extraData", + {"foo": "bar"}, + "NaturalPersonCredential-unexpected-property", + ), + # Extra field on the credential subject (closed LegalPerson shape) + ( + "legal-person-credential.json", + None, # special handling — add to credentialSubject + None, + "LegalPerson-subject-unexpected-property", + ), +] + + +@_skip_no_artifacts +@_skip_no_omb +class TestClosedShapeViolations: + """Adding an unexpected property to a closed shape must be caught. + + Harbour credential shapes use ``sh:closed true`` — any property not + declared in the shape is a violation. This protects against typos + and schema drift. + + Debugging: if a test passes unexpectedly, the shape may not be closed + (check ``sh:closed true`` in the SHACL TTL). + """ + + @pytest.mark.parametrize( + "example_file, field_name, field_value, test_id", + _CLOSED_SHAPE_CASES, + ids=[c[3] for c in _CLOSED_SHAPE_CASES], + ) + def test_unexpected_property_detected( + self, + example_file, + field_name, + field_value, + test_id, + context_url_map, + shacl_graph, + ): + cred = _load_example(example_file) + + if field_name is None: + # Add to credentialSubject instead of top level + mutated = copy.deepcopy(cred) + mutated["credentialSubject"]["harbour.gx:unexpectedField"] = "surprise" + else: + mutated = _add_field(cred, field_name, field_value) + + conforms, violations, text = _validate(mutated, context_url_map, shacl_graph) + + assert not conforms, ( + f"[{test_id}] Credential with unexpected property should FAIL " + f"but SHACL said it conforms.\n" + f"Check that the shape has sh:closed true." + ) + + closed_violations = [ + v for v in violations if v.constraint == "ClosedConstraintComponent" + ] + assert closed_violations, ( + f"[{test_id}] Expected ClosedConstraintComponent but got:\n" + f"{_format_violations(violations)}\n\n" + f"Full SHACL report:\n{text}" + ) + + +# --------------------------------------------------------------------------- +# C4: Cardinality violations (sh:MaxCountConstraintComponent) +# --------------------------------------------------------------------------- + + +@_skip_no_artifacts +@_skip_no_omb +class TestCardinalityViolations: + """Exceeding sh:maxCount on a property must be caught. + + Certain fields like ``issuer`` and ``validFrom`` are constrained to + exactly one value (sh:minCount 1, sh:maxCount 1). Providing multiple + values must trigger a MaxCountConstraintComponent. + """ + + def test_multiple_issuers(self, context_url_map, shacl_graph): + """Two issuers should violate sh:maxCount 1.""" + cred = _load_example("legal-person-credential.json") + # JSON-LD doesn't naturally support duplicate keys, but we can + # test by making issuer an array (which expands to multiple values) + mutated = _set_field( + cred, + ["did:ethr:0x14a34:0xaaaa", "did:ethr:0x14a34:0xbbbb"], + "issuer", + ) + conforms, violations, text = _validate(mutated, context_url_map, shacl_graph) + assert not conforms, ( + "Two issuers should violate maxCount but SHACL said it conforms.\n" + f"Full report:\n{text}" + ) + + def test_multiple_valid_from(self, context_url_map, shacl_graph): + """Two validFrom dates should violate sh:maxCount 1.""" + cred = _load_example("natural-person-credential.json") + mutated = _set_field( + cred, + ["2025-01-15T00:00:00Z", "2025-06-01T00:00:00Z"], + "validFrom", + ) + conforms, violations, text = _validate(mutated, context_url_map, shacl_graph) + assert not conforms, ( + "Two validFrom values should violate maxCount but SHACL said it conforms.\n" + f"Full report:\n{text}" + ) + + def test_multiple_label_levels(self, context_url_map, shacl_graph): + """Two labelLevel values should violate sh:maxCount 1.""" + cred = _load_example("legal-person-credential.json") + mutated = _set_field( + cred, + ["SC", "L1"], + "credentialSubject", + "harbour.gx:labelLevel", + ) + conforms, violations, text = _validate(mutated, context_url_map, shacl_graph) + assert not conforms, ( + "Two labelLevel values should violate maxCount " + f"but SHACL said it conforms.\nFull report:\n{text}" + ) From 7645fb6075b4e081a3410bbc939829d59636d51a Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Mon, 16 Mar 2026 10:51:15 +0100 Subject: [PATCH 34/78] docs(guide): add credential lifecycle with swimlane diagrams Add comprehensive credential lifecycle guide covering: - Company onboarding flow (4 credentials, 3 automated steps) - Employee onboarding flow (1 credential, employer VP authorization) - Delegated transaction flow (consent VP, blockchain execution, receipt) - Presentation scenarios with selective disclosure tables - Automation summary showing which steps are manual vs automated - Wallet contents at each stage with credential counts Uses mermaid sequence diagrams for swimlane visualization. Signed-off-by: Carlo van Driesten --- docs/guide/credential-lifecycle.md | 270 +++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 271 insertions(+) create mode 100644 docs/guide/credential-lifecycle.md diff --git a/docs/guide/credential-lifecycle.md b/docs/guide/credential-lifecycle.md new file mode 100644 index 0000000..bd17b63 --- /dev/null +++ b/docs/guide/credential-lifecycle.md @@ -0,0 +1,270 @@ +# Credential Lifecycle + +This guide traces every credential through the harbour ecosystem — from +issuance to the user's wallet to presentation. The three swimlane +diagrams show **who does what** and which credentials are created at each +step. + +!!! tip "Automation note" + Steps marked with ⚙️ are automated by Haven (the compliance service). + The user interacts only with steps marked 👤. + +--- + +## Flow A — Company (LegalPerson) Onboarding + +A company joins the ecosystem and obtains a Gaia-X–compliant identity. + +```mermaid +sequenceDiagram + participant C as 👤 Company + participant H as ⚙️ Haven + participant N as ⚙️ Notary (VIES) + participant TA as ⚙️ Trust Anchor + participant BC as ⛓️ Blockchain + + Note over C,BC: Phase 1 — DID Registration + C->>H: Request organisation onboarding + H->>BC: CREATE2 keyless DID address + BC-->>H: did:ethr:eip155:8453:0x… + H-->>C: DID assigned + + Note over C,BC: Phase 2 — Gaia-X Input Credentials + C->>H: Submit company data (name, address, VAT#) + H->>H: ⚙️ Build gx:LegalPerson VC (self-signed by company) + H->>H: ⚙️ Build gx:Issuer VC (T&C hash, self-signed) + H->>N: ⚙️ Verify VAT against VIES/GLEIF + N-->>H: VAT confirmed + H->>H: ⚙️ Build gx:VatID VC (notary-signed) + + Note over C,BC: Phase 3 — Compliance Credential + TA->>H: ⚙️ Present LinkedCredentialService VP (authorization) + H->>H: ⚙️ Verify TA VP + 3 GX VCs + compute SRI hashes + H->>H: ⚙️ Build LegalPersonCredential (references + evidence) + H->>H: ⚙️ Sign credential as JWT (Haven key) + H-->>C: 📥 4 credentials delivered to wallet +``` + +### Company wallet after onboarding + +| # | Credential | Type | Signed by | Purpose | +|---|-----------|------|-----------|---------| +| 1 | `gx:LegalPerson` | Plain Gaia-X | Company (self) | Entity identity data | +| 2 | `gx:VatID` | Plain Gaia-X | Notary | VAT verification | +| 3 | `gx:Issuer` | Plain Gaia-X | Company (self) | T&C acceptance hash | +| 4 | **`harbour.gx:LegalPersonCredential`** | **Harbour compliance** | **Haven** | **Proof of Gaia-X compliance** | + +!!! info "What the user actually does" + The company fills out **one form** (name, address, VAT number, T&C + checkbox). Haven orchestrates all four credentials automatically. + The user never sees the individual GX VCs — they arrive as a bundle + in the wallet. + +### What makes credential #4 special + +The `LegalPersonCredential` is the **compliance stamp**. Its +`credentialSubject` contains: + +| Field | Value | Meaning | +|-------|-------|---------| +| `compliantLegalPersonVC` | SRI hash | Links to gx:LegalPerson VC, verifiable by any party | +| `compliantRegistrationVC` | SRI hash | Links to gx:VatID VC | +| `compliantTermsVC` | SRI hash | Links to gx:Issuer VC | +| `labelLevel` | `SC` | Gaia-X Standard Compliance | +| `rulesVersion` | `CD25.10` | Loire compliance document version | +| `validatedCriteria` | `[PA1.1]` | Specific criteria checked | + +The `evidence` field contains the Trust Anchor's VP — proving Haven was +authorized to issue this credential. + +--- + +## Flow B — Employee (NaturalPerson) Onboarding + +An employee of a compliant company obtains personal credentials. + +```mermaid +sequenceDiagram + participant E as 👤 Employee + participant C as 👤 Company + participant H as ⚙️ Haven + participant BC as ⛓️ Blockchain + + Note over E,BC: Phase 1 — DID Registration + E->>H: Request employee onboarding + H->>BC: CREATE2 keyless DID from employee P-256 key + BC-->>H: did:ethr:eip155:8453:0x… + H->>BC: Set P-256 public key in DID document + H-->>E: DID assigned (key in SSI wallet) + + Note over E,BC: Phase 2 — Employer Authorization + C->>H: ⚙️ Present LegalPersonCredential VP (SD-JWT) + Note right of C: PII redacted via selective disclosure:
addresses + registration hidden,
only compliance status visible + H->>H: ⚙️ Verify employer VP + check CRSet revocation + + Note over E,BC: Phase 3 — Employee Credential + H->>H: ⚙️ Build NaturalPersonCredential + H->>H: ⚙️ Attach evidence (employer's VP) + H->>H: ⚙️ Sign credential as JWT (Haven key) + H-->>E: 📥 1 credential delivered to wallet +``` + +### Employee wallet after onboarding + +| # | Credential | Type | Signed by | Purpose | +|---|-----------|------|-----------|---------| +| 1 | **`harbour.gx:NaturalPersonCredential`** | **Harbour** | **Haven** | Employee identity + employer link | + +!!! info "What the user actually does" + The employee opens the wallet app and confirms their details (name, + email). The employer approves in their admin panel. Haven handles + the VP exchange and issuance automatically. + +### Selective disclosure in the evidence + +When the company authorizes employee issuance, its +`LegalPersonCredential` is presented as an **SD-JWT** — sensitive +details are hidden: + +| Field | Disclosed? | Reason | +|-------|-----------|--------| +| `labelLevel` | ✅ Yes | Proves compliance status | +| `compliantLegalPersonVC` | ✅ Yes | Proves GX VC was verified | +| Company name | ❌ Hidden | Not needed for authorization | +| Company address | ❌ Hidden | PII minimization | +| VAT number | ❌ Hidden | Sensitive business data | + +--- + +## Flow C — Delegated Transaction (Marketplace Purchase) + +An employee authorizes a blockchain transaction through the signing +service. + +```mermaid +sequenceDiagram + participant E as 👤 Employee + participant M as 🏪 Marketplace + participant H as ⚙️ Haven + participant BC as ⛓️ Blockchain + + Note over E,BC: Phase 1 — Transaction Request + E->>M: 👤 Browse marketplace, click "Buy" + M->>H: Create transaction request (asset, price, currency) + H->>H: ⚙️ Generate OID4VP challenge (nonce + transaction_data) + H-->>E: 📱 Display transaction details for review + + Note over E,BC: Phase 2 — User Consent (OID4VP) + E->>E: 👤 Review transaction in wallet + E->>E: 👤 Approve — wallet builds SD-JWT VP + Note right of E: Selective disclosure:
✅ memberOf (employer DID)
❌ givenName, familyName, email
KB-JWT binds nonce to tx hash + E->>H: Submit consent VP + + Note over E,BC: Phase 3 — Execution + H->>H: ⚙️ Verify consent VP (signature + nonce + revocation) + H->>BC: ⚙️ Execute blockchain transaction + BC-->>H: Transaction hash + block confirmation + + Note over E,BC: Phase 4 — Receipt + H->>H: ⚙️ Build DelegatedSigningReceipt + H->>H: ⚙️ Attach DelegatedSignatureEvidence (consent VP + tx data) + H->>H: ⚙️ Sign receipt as JWT (Haven key) + H-->>E: 📥 Receipt delivered to wallet +``` + +### Employee wallet after transaction + +| # | Credential | Added when | Purpose | +|---|-----------|-----------|---------| +| 1 | `NaturalPersonCredential` | Onboarding | Identity | +| 2 | **`DelegatedSigningReceipt`** | **This transaction** | **Proof of purchase** | + +!!! info "What the user actually does" + Click "Buy" → review transaction details in wallet → tap + "Approve". Three taps total. Haven handles the cryptographic + consent protocol, blockchain execution, and receipt issuance. + +### Three-layer privacy on the receipt + +The `DelegatedSigningReceipt` uses selective disclosure to support +different audit levels: + +| Layer | Who can see | What's visible | +|-------|-----------|---------------| +| **Public** | Anyone | CRSet entry exists, transaction hash on-chain | +| **Authorized audit** | Regulator / marketplace | Transaction details (asset, price, marketplace DID) | +| **Full compliance** | Court order / internal | Employee identity (name, email, employer) | + +--- + +## Presentation Scenarios + +### Company presents to a verifier + +The company bundles all four credentials into a single +`VerifiablePresentation`: + +```mermaid +graph LR + VP[Verifiable Presentation] + VP --> LP[gx:LegalPerson
Entity data] + VP --> VAT[gx:VatID
VAT verified] + VP --> TC[gx:Issuer
T&C hash] + VP --> HC[harbour.gx:LegalPersonCredential
Compliance stamp] + + style HC fill:#2d6,stroke:#1a4,color:#fff + style VP fill:#36a,stroke:#258,color:#fff +``` + +A verifier checks: + +1. **`LegalPersonCredential`** signed by Haven? → Trusted issuer +2. SRI hashes match the three GX VCs? → Integrity verified +3. `labelLevel` = SC? → Gaia-X compliant +4. CRSet entry not revoked? → Still valid +5. Evidence VP from Trust Anchor? → Authorization chain intact + +### Employee presents to a service + +The employee selectively discloses only what's needed: + +| Scenario | Disclosed fields | Hidden fields | +|----------|-----------------|---------------| +| **Marketplace login** | `memberOf` (employer) | name, email, address | +| **KYC check** | name, email, `memberOf` | address | +| **Full identification** | All fields | (nothing) | + +--- + +## Credential Count Summary + +| Role | Onboarding credentials | Per transaction | Wallet total (after 5 txns) | +|------|----------------------|-----------------|---------------------------| +| **Company** | 4 (3 GX + 1 harbour) | 0 | 4 | +| **Employee** | 1 | +1 receipt each | 6 | + +!!! note "Scaling consideration" + Transaction receipts accumulate. A wallet app should archive older + receipts or support pagination. The receipts themselves are small + (single JWT) — storage is not a concern, but UX presentation is. + +--- + +## Automation Summary + +| Step | Manual (👤) | Automated (⚙️) | Why automated? | +|------|-----------|---------------|---------------| +| Company data entry | 👤 Form | — | Only the company knows its data | +| VAT verification | — | ⚙️ VIES API | Deterministic lookup, no human judgment | +| GX VC creation | — | ⚙️ Haven builds 3 VCs | Standard format, no decisions needed | +| Trust Anchor auth | — | ⚙️ TA presents VP | Pre-configured trust relationship | +| Compliance credential | — | ⚙️ Haven issues | Rule-based: 3 VCs present + valid → issue | +| Employee data entry | 👤 Confirm details | — | Only the employee knows their data | +| Employer approval | 👤 Admin panel | — | Business decision | +| Employee credential | — | ⚙️ Haven issues | Employer VP valid → issue | +| Transaction review | 👤 Approve in wallet | — | Must be explicit user consent | +| Blockchain execution | — | ⚙️ Haven executes | Technical step, consent already given | +| Receipt issuance | — | ⚙️ Haven issues | Automatic after successful execution | + +**Bottom line:** The user makes **3 decisions** (enter data, approve +employee, approve transaction). Everything else is automated. diff --git a/mkdocs.yml b/mkdocs.yml index c25b729..808ba27 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -65,6 +65,7 @@ nav: - Installation: getting-started/installation.md - Quick Start: getting-started/quickstart.md - Guide: + - Credential Lifecycle: guide/credential-lifecycle.md - Delegated Signing: guide/delegated-signing.md - Evidence Types: guide/evidence.md - Specifications: From a568379b5456c8f8f950264f6901fa3ce2f18fcc Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Mon, 16 Mar 2026 10:55:27 +0100 Subject: [PATCH 35/78] docs: promote credential lifecycle guide on home page Add callout box directing new users to the lifecycle guide first. Move lifecycle guide to top of Documentation links list. Signed-off-by: Carlo van Driesten --- docs/index.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/index.md b/docs/index.md index 797bce4..35f1494 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,6 +2,11 @@ **Harbour Credentials** is a cryptographic library for signing and verifying verifiable credentials. It provides dual-runtime support for both Python and TypeScript with feature parity. +!!! tip "New here?" + Start with the [Credential Lifecycle](guide/credential-lifecycle.md) guide + to see how companies and employees get credentialed, how transactions + work, and what ends up in each wallet — with swimlane diagrams. + ## Features - 🔑 **Key Management** — P-256 and Ed25519 key generation with DID:key encoding @@ -65,6 +70,7 @@ npm install @reachhaven/harbour-credentials ## Documentation +- [Credential Lifecycle](guide/credential-lifecycle.md) — **Start here**: onboarding flows, wallet contents, presentation scenarios - [Installation](getting-started/installation.md) — Detailed setup instructions - [Quick Start](getting-started/quickstart.md) — Get up and running - [CLI Reference](cli/index.md) — Command-line tools From 42e6dd1adfd8d88760ed5a50e5a27dde6a482121 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Tue, 17 Mar 2026 12:42:06 +0100 Subject: [PATCH 36/78] chore: update yarn version and OMB pin to main Signed-off-by: Carlo van Driesten --- src/typescript/harbour/package.json | 2 +- submodules/ontology-management-base | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/typescript/harbour/package.json b/src/typescript/harbour/package.json index f703dc0..04f18e7 100644 --- a/src/typescript/harbour/package.json +++ b/src/typescript/harbour/package.json @@ -5,7 +5,7 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", - "packageManager": "yarn@4.9.2", + "packageManager": "yarn@4.13.0", "scripts": { "build": "tsc", "test": "vitest run", diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index 4327579..4da2c3f 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit 43275794dac66649fc3e6b9c154bef74a957d234 +Subproject commit 4da2c3ff238ad30f0507b2c37ac7d03ffc84df79 From d51b76c120994bc2808f1ec4edfaf02cbe27fcc7 Mon Sep 17 00:00:00 2001 From: jdsika Date: Wed, 18 Mar 2026 21:37:50 +0100 Subject: [PATCH 37/78] chore: update OMB pin to main (linkml fork + deterministic output) Signed-off-by: jdsika --- submodules/ontology-management-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index 4da2c3f..6713639 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit 4da2c3ff238ad30f0507b2c37ac7d03ffc84df79 +Subproject commit 6713639deb37c84f6b6b1bce3f54af9d4d907534 From a123e3ef0b1a90ec5c4ef0f371832f4b6bfb3fa8 Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 20 Mar 2026 10:38:37 +0100 Subject: [PATCH 38/78] feat(release): add w3id artifact publishing to release workflow - Add release-artifacts Makefile target copying artifacts to w3id directory structure for GitHub Pages publishing - Replace cd-release.yml docs job with full publish pipeline: generate, validate, build docs, copy artifacts, deploy to Pages - Support release candidate tags (v*.*.*-rc.*) - Update w3id.org submodule with reachhaven namespace redirect rules Signed-off-by: jdsika --- .github/workflows/cd-release.yml | 74 ++++++- .github/workflows/ci.yml | 2 +- CLAUDE.md | 2 +- Makefile | 268 +++++++++++++++-------- README.md | 8 +- docs/contributing.md | 5 + docs/getting-started/installation.md | 11 +- linkml/harbour-core-credential.yaml | 51 +++-- linkml/harbour-core-delegation.yaml | 2 +- linkml/harbour-gx-credential.yaml | 40 +++- linkml/w3c-vc.yaml | 48 +++- src/python/harbour/generate_artifacts.py | 107 ++------- submodules/w3id.org | 2 +- 13 files changed, 386 insertions(+), 234 deletions(-) diff --git a/.github/workflows/cd-release.yml b/.github/workflows/cd-release.yml index 1f03509..7ee4924 100644 --- a/.github/workflows/cd-release.yml +++ b/.github/workflows/cd-release.yml @@ -4,11 +4,12 @@ on: push: tags: - 'v*.*.*' + - 'v*.*.*-rc.*' workflow_dispatch: inputs: tag: - description: 'Release tag (e.g., v0.1.0)' + description: 'Release tag (e.g., v1.0.0 or v1.0.0-rc.1)' required: true type: string @@ -64,17 +65,76 @@ jobs: name: Release ${{ steps.tag.outputs.tag }} body: ${{ steps.changelog.outputs.content }} draft: false - prerelease: false + prerelease: ${{ contains(steps.tag.outputs.tag, '-rc.') }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - docs: - name: 📚 Publish Documentation + publish: + name: 📦 Publish Artifacts & Docs needs: release - uses: ./.github/workflows/cd-docs.yml - with: - ref: main + runs-on: ubuntu-latest permissions: contents: read pages: write id-token: write + concurrency: + group: "pages" + cancel-in-progress: false + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: recursive + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: 'pip' + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Enable Corepack + run: corepack enable + + - name: Install Python dependencies + run: python3 -m pip install -e ".[dev,docs]" + + - name: Install TypeScript dependencies + working-directory: src/typescript/harbour + run: yarn install --immutable + + - name: Generate artifacts + run: make generate + + - name: Validate artifacts + run: make validate shacl + + - name: Generate TypeScript API docs + working-directory: src/typescript/harbour + run: npx typedoc --out ../../../docs/api/typescript index.ts --skipErrorChecking + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Build MkDocs site + run: mkdocs build --strict --site-dir site + + - name: Prepare w3id artifacts + run: make release-artifacts + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./site + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91c4dec..ccead66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,7 +72,7 @@ jobs: run: make install dev - name: Install ontology-management-base - run: make submodule-setup + run: make setup submodules - name: Generate artifacts run: make generate diff --git a/CLAUDE.md b/CLAUDE.md index 314cff3..6249db7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,7 @@ Tests live in `tests/` with shared fixtures: ### TypeScript Toolchain -- Package manager: **Yarn 4** via corepack (`corepack enable`) +- Package manager: **Yarn 4** via corepack (`corepack yarn ...`) - Test runner: **vitest** (config in `src/typescript/harbour/vitest.config.ts`) - Build: `tsc` (strict mode, ES2022 target) - Package: `@reachhaven/harbour-credentials` diff --git a/Makefile b/Makefile index 21c7075..11dcf1c 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,10 @@ # Harbour Credentials Makefile # ============================ -.PHONY: setup install submodule-setup ts-bootstrap generate validate lint format test build story all clean help \ - _help_general _help_install _help_validate _help_lint _help_format _help_test _help_story _help_build \ - _install_default _install_dev \ +.PHONY: setup install generate validate lint format test build story all clean help \ + release-artifacts \ + _help_general _help_setup _help_install _help_validate _help_lint _help_format _help_test _help_story _help_build \ + _setup_default _setup_submodules _setup_ts _install_default _install_dev \ _validate_default _validate_shacl \ _lint_default _lint_md _lint_ts \ _format_default _format_md \ @@ -14,39 +15,66 @@ TS_DIR := src/typescript/harbour OMB_SUBMODULE_DIR := submodules/ontology-management-base -# In CI, use system Python; locally, prefer parent venv then local .venv -ifdef CI - VENV := $(dir $(shell which python3)).. - PYTHON := python3 +# Allow callers to override the venv path/tooling. +VENV ?= .venv + +# OS detection for cross-platform support (Windows vs Unix) +ifeq ($(OS),Windows_NT) + ifndef CI + ifneq ($(wildcard ../../.venv/Scripts/python.exe),) + VENV := ../../.venv + endif + endif + VENV_BIN := $(VENV)/Scripts + VENV_PYTHON := $(VENV_BIN)/python.exe + ifdef CI + PYTHON ?= python + else + PYTHON ?= $(VENV_PYTHON) + endif + BOOTSTRAP_PYTHON ?= python + ACTIVATE_SCRIPT := $(VENV_BIN)/activate + ACTIVATE_HINT := PowerShell: $(subst /,\,$(VENV_BIN))\Activate.ps1; Git Bash: source $(ACTIVATE_SCRIPT) + PYTHONPATH_SEP := ; else ifneq ($(wildcard ../../.venv/bin/python3),) VENV := ../../.venv + endif + VENV_BIN := $(VENV)/bin + VENV_PYTHON := $(VENV_BIN)/python3 + ifdef CI + PYTHON ?= python3 else - VENV := .venv + PYTHON ?= $(VENV_PYTHON) endif - PYTHON := $(VENV)/bin/python3 + BOOTSTRAP_PYTHON ?= python3 + ACTIVATE_SCRIPT := $(VENV_BIN)/activate + ACTIVATE_HINT := source $(ACTIVATE_SCRIPT) + PYTHONPATH_SEP := : endif -# Bootstrap interpreter used only to create the venv -BOOTSTRAP_PYTHON := python3 - # Absolute path to Python (for use after cd into subdirectories). # In CI, PYTHON is a bare command ('python3') so resolve via PATH; # locally it is a relative venv path so abspath works. +# When a parent Makefile passes an already-absolute Windows path +# (containing ':'), $(abspath) would mangle it — skip in that case. ifdef CI - PYTHON_ABS := $(shell which $(PYTHON)) + PYTHON_ABS := $(shell command -v $(PYTHON)) +else ifneq ($(findstring :,$(PYTHON)),) + PYTHON_ABS := $(PYTHON) else PYTHON_ABS := $(abspath $(PYTHON)) endif # Tooling inside the selected virtual environment -PIP := $(PYTHON) -m pip -PRECOMMIT := $(PYTHON) -m pre_commit -PYTEST := $(PYTHON) -m pytest +PIP := "$(PYTHON)" -m pip +PRECOMMIT := "$(PYTHON)" -m pre_commit +PYTEST := "$(PYTHON)" -m pytest +YARN := corepack yarn # Check if dev environment is set up (skipped in CI) define check_dev_setup - @if [ -z "$$CI" ] && [ ! -x "$(PYTHON)" ]; then \ + @if [ -z "$$CI" ] && [ ! -f "$(PYTHON)" ]; then \ echo ""; \ echo "ERROR: Development environment not set up."; \ echo ""; \ @@ -55,7 +83,7 @@ define check_dev_setup echo ""; \ exit 1; \ fi - @if ! $(PYTHON) -c "import linkml" 2>/dev/null; then \ + @if ! "$(PYTHON)" -c "import linkml" 2>/dev/null; then \ echo ""; \ echo "ERROR: Dev dependencies not installed."; \ echo ""; \ @@ -73,19 +101,19 @@ HARBOUR_EXAMPLE_FILES := $(wildcard examples/*.json) $(wildcard examples/gaiax/* HARBOUR_VALIDATE_PATH ?= HARBOUR_VALIDATE_ALLOW_ONLINE ?= 1 HARBOUR_VALIDATE_ENFORCE_REQUIRED_ONTOLOGIES ?= $(if $(strip $(HARBOUR_VALIDATE_PATH)),0,1) -GROUPED_COMMANDS := install validate lint format test story build +GROUPED_COMMANDS := setup install validate lint format test story build PRIMARY_GOAL := $(firstword $(MAKECMDGOALS)) +SUBCOMMAND_GOALS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS)) # Grouped command mode: treat trailing goals as subcommands ifneq ($(filter $(PRIMARY_GOAL),$(GROUPED_COMMANDS)),) -help: - @: +.PHONY: $(SUBCOMMAND_GOALS) -%: +$(SUBCOMMAND_GOALS): @: else help: - @$(MAKE) --no-print-directory _help_general + @"$(MAKE)" --no-print-directory _help_general endif # Default target @@ -94,9 +122,9 @@ _help_general: @echo "" @echo "Installation:" @echo " make setup - Create venv, install dev dependencies, setup ontology submodule, and bootstrap TypeScript" + @echo " make setup help - Show setup subcommands" @echo " make install - Install package (user mode)" @echo " make install help - Show install subcommands" - @echo " make ts-bootstrap - Enable corepack and install TypeScript dependencies" @echo "" @echo "Artifacts:" @echo " make generate - Generate OWL/SHACL/context from LinkML" @@ -122,6 +150,12 @@ _help_general: @echo "Cleaning:" @echo " make clean - Remove build artifacts and caches" +_help_setup: + @echo "Setup subcommands:" + @echo " make setup - Create venv, install dev dependencies, setup ontology submodule, and bootstrap TypeScript" + @echo " make setup submodules - Setup the ontology-management-base submodule in the active environment" + @echo " make setup ts - Bootstrap TypeScript dependencies" + _help_install: @echo "Install subcommands:" @echo " make install - Install package (user mode)" @@ -166,30 +200,58 @@ _help_build: # Create virtual environment and install dependencies setup: + @set -- $(filter-out $@,$(MAKECMDGOALS)); \ + subcommand="$${1:-default}"; \ + if [ "$$#" -gt 1 ]; then \ + echo "ERROR: Too many subcommands for 'make setup': $(filter-out $@,$(MAKECMDGOALS))"; \ + echo "Run 'make setup help' for available options."; \ + exit 1; \ + fi; \ + case "$$subcommand" in \ + default) "$(MAKE)" --no-print-directory _setup_default ;; \ + submodules) "$(MAKE)" --no-print-directory _setup_submodules ;; \ + ts) "$(MAKE)" --no-print-directory _setup_ts ;; \ + help) "$(MAKE)" --no-print-directory _help_setup ;; \ + *) echo "ERROR: Unknown setup subcommand '$$subcommand'"; echo "Run 'make setup help' for available options."; exit 1 ;; \ + esac + +_setup_default: @echo "Setting up development environment..." @echo "Checking Python virtual environment and dependencies..." +ifdef CI @set -e; \ - if [ ! -x "$(PYTHON)" ]; then \ + if "$(PYTHON)" -c "import pre_commit, linkml" >/dev/null 2>&1; then \ + echo "OK: Python environment and dependencies are ready via $(PYTHON)"; \ + else \ + echo "CI environment missing dependencies; bootstrapping..."; \ + $(PIP) install -e ".[dev]"; \ + $(PIP) install linkml; \ + $(PRECOMMIT) install; \ + fi +else + @set -e; \ + if [ ! -f "$(PYTHON)" ]; then \ echo "Python virtual environment not found; bootstrapping..."; \ - $(MAKE) --no-print-directory $(VENV)/bin/activate; \ - elif $(PYTHON) -c "import pre_commit, linkml" >/dev/null 2>&1; then \ + "$(MAKE)" --no-print-directory "$(ACTIVATE_SCRIPT)"; \ + elif "$(PYTHON)" -c "import pre_commit, linkml" >/dev/null 2>&1; then \ echo "OK: Python virtual environment and dependencies are ready at $(VENV)"; \ else \ echo "Python virtual environment found but dependencies are missing; bootstrapping..."; \ - $(MAKE) --no-print-directory -B $(VENV)/bin/activate; \ + "$(MAKE)" --no-print-directory -B "$(ACTIVATE_SCRIPT)"; \ fi - @$(MAKE) --no-print-directory submodule-setup - @$(MAKE) --no-print-directory ts-bootstrap +endif + @"$(MAKE)" --no-print-directory setup submodules + @"$(MAKE)" --no-print-directory setup ts @echo "" - @echo "Setup complete. Activate with: source $(VENV)/bin/activate" + @echo "Setup complete. Activate with: $(ACTIVATE_HINT)" -$(VENV)/bin/python3: +$(VENV_PYTHON): @echo "Creating Python virtual environment at $(VENV)..." - @$(BOOTSTRAP_PYTHON) -m venv $(VENV) + @"$(BOOTSTRAP_PYTHON)" -m venv "$(VENV)" @$(PIP) install --upgrade pip @echo "OK: Python virtual environment ready" -$(VENV)/bin/activate: $(VENV)/bin/python3 +$(ACTIVATE_SCRIPT): $(VENV_PYTHON) @echo "Installing Python dependencies..." @$(PIP) install -e ".[dev]" @$(PIP) install linkml @@ -197,28 +259,25 @@ $(VENV)/bin/activate: $(VENV)/bin/python3 @echo "OK: Python development environment ready" # Setup ontology-management-base submodule using the same active venv -submodule-setup: +_setup_submodules: @echo "Setting up ontology-management-base submodule..." @set -e; \ if [ -f "$(OMB_SUBMODULE_DIR)/setup.py" ] || [ -f "$(OMB_SUBMODULE_DIR)/pyproject.toml" ]; then \ $(PIP) install -e "$(OMB_SUBMODULE_DIR)"; \ echo "OK: ontology-management-base submodule setup complete"; \ elif [ -f "$(OMB_SUBMODULE_DIR)/Makefile" ]; then \ - $(MAKE) --no-print-directory -C $(OMB_SUBMODULE_DIR) setup \ + "$(MAKE)" --no-print-directory -C "$(OMB_SUBMODULE_DIR)" setup \ VENV="$(abspath $(VENV))" \ - PYTHON="$(PYTHON_ABS)" \ - PIP="$(PYTHON_ABS) -m pip" \ - PRECOMMIT="$(PYTHON_ABS) -m pre_commit" \ - PYTEST="$(PYTHON_ABS) -m pytest"; \ + PYTHON="$(PYTHON_ABS)"; \ echo "OK: ontology-management-base submodule setup complete"; \ else \ echo "WARNING: Skipping ontology-management-base submodule setup (not found)"; \ fi # Bootstrap TypeScript toolchain -ts-bootstrap: +_setup_ts: @echo "Bootstrapping TypeScript dependencies..." - @cd $(TS_DIR) && corepack enable && yarn install + @cd "$(TS_DIR)" && $(YARN) install @echo "OK: TypeScript bootstrap complete" # Install package (user mode) @@ -231,16 +290,16 @@ install: exit 1; \ fi; \ case "$$subcommand" in \ - default) $(MAKE) --no-print-directory _install_default ;; \ - dev) $(MAKE) --no-print-directory _install_dev ;; \ - help) $(MAKE) --no-print-directory _help_install ;; \ + default) "$(MAKE)" --no-print-directory _install_default ;; \ + dev) "$(MAKE)" --no-print-directory _install_dev ;; \ + help) "$(MAKE)" --no-print-directory _help_install ;; \ *) echo "ERROR: Unknown install subcommand '$$subcommand'"; echo "Run 'make install help' for available options."; exit 1 ;; \ esac _install_default: @echo "Installing package in editable mode..." ifndef CI - @$(MAKE) --no-print-directory $(VENV)/bin/python3 + @"$(MAKE)" --no-print-directory "$(VENV_PYTHON)" endif @$(PIP) install -e . @echo "OK: Package installation complete" @@ -249,7 +308,7 @@ endif _install_dev: @echo "Installing development dependencies..." ifndef CI - @$(MAKE) --no-print-directory $(VENV)/bin/python3 + @"$(MAKE)" --no-print-directory "$(VENV_PYTHON)" endif @$(PIP) install -e ".[dev]" @$(PIP) install linkml @@ -262,7 +321,7 @@ endif generate: $(call check_dev_setup) @echo "Generating artifacts from LinkML schemas..." - @PYTHONPATH=src/python:$$PYTHONPATH $(PYTHON) src/python/harbour/generate_artifacts.py + @PYTHONPATH="src/python$(PYTHONPATH_SEP)$$PYTHONPATH" "$(PYTHON)" src/python/harbour/generate_artifacts.py @echo "" @echo "OK: Artifacts generated in artifacts/" @@ -276,23 +335,23 @@ validate: exit 1; \ fi; \ case "$$subcommand" in \ - default) $(MAKE) --no-print-directory _validate_default ;; \ - shacl) $(MAKE) --no-print-directory _validate_shacl ;; \ - help) $(MAKE) --no-print-directory _help_validate ;; \ + default) "$(MAKE)" --no-print-directory _validate_default ;; \ + shacl) "$(MAKE)" --no-print-directory _validate_shacl ;; \ + help) "$(MAKE)" --no-print-directory _help_validate ;; \ *) echo "ERROR: Unknown validate subcommand '$$subcommand'"; echo "Run 'make validate help' for available options."; exit 1 ;; \ esac _validate_default: $(call check_dev_setup) @echo "Validating harbour credentials..." - @PYTHONPATH=src/python:$$PYTHONPATH $(PYTEST) tests/python/credentials/test_validation.py -v + @PYTHONPATH="src/python$(PYTHONPATH_SEP)$$PYTHONPATH" $(PYTEST) tests/python/credentials/test_validation.py -v @echo "OK: Validation complete" # Validate example credentials against SHACL shapes via ontology-management-base _validate_shacl: $(call check_dev_setup) @echo "Running SHACL data conformance check on examples..." - @cd $(OMB_SUBMODULE_DIR) && \ + @cd "$(OMB_SUBMODULE_DIR)" && \ tmp_output=$$(mktemp) && \ allow_online_flag="" ; \ if [ "$(HARBOUR_VALIDATE_ALLOW_ONLINE)" = "0" ]; then \ @@ -317,13 +376,13 @@ _validate_shacl: rm -f $$tmp_output ; \ exit 1 ; \ fi ; \ - $(PYTHON_ABS) -m src.tools.validators.validation_suite \ + "$(PYTHON_ABS)" -m src.tools.validators.validation_suite \ --run check-data-conformance \ $$allow_online_flag \ --data-paths "$$target_path" ../../examples/did-ethr/ ../../tests/validation-probe/ontology-loading-probe.json \ --artifacts ../../artifacts > $$tmp_output 2>&1 ; \ else \ - $(PYTHON_ABS) -m src.tools.validators.validation_suite \ + "$(PYTHON_ABS)" -m src.tools.validators.validation_suite \ --run check-data-conformance \ $$allow_online_flag \ --data-paths $(addprefix ../../,$(HARBOUR_EXAMPLE_FILES)) ../../examples/did-ethr/ ../../tests/validation-probe/ontology-loading-probe.json \ @@ -361,17 +420,17 @@ lint: exit 1; \ fi; \ case "$$subcommand" in \ - default) $(MAKE) --no-print-directory _lint_default ;; \ - md) $(MAKE) --no-print-directory _lint_md ;; \ - ts) $(MAKE) --no-print-directory _lint_ts ;; \ - help) $(MAKE) --no-print-directory _help_lint ;; \ + default) "$(MAKE)" --no-print-directory _lint_default ;; \ + md) "$(MAKE)" --no-print-directory _lint_md ;; \ + ts) "$(MAKE)" --no-print-directory _lint_ts ;; \ + help) "$(MAKE)" --no-print-directory _help_lint ;; \ *) echo "ERROR: Unknown lint subcommand '$$subcommand'"; echo "Run 'make lint help' for available options."; exit 1 ;; \ esac _lint_default: $(call check_dev_setup) @echo "Running pre-commit checks..." - @$(PYTHON) -m pre_commit run --all-files + @"$(PYTHON)" -m pre_commit run --all-files @echo "OK: Pre-commit checks complete" # Lint Markdown files @@ -390,17 +449,17 @@ format: exit 1; \ fi; \ case "$$subcommand" in \ - default) $(MAKE) --no-print-directory _format_default ;; \ - md) $(MAKE) --no-print-directory _format_md ;; \ - help) $(MAKE) --no-print-directory _help_format ;; \ + default) "$(MAKE)" --no-print-directory _format_default ;; \ + md) "$(MAKE)" --no-print-directory _format_md ;; \ + help) "$(MAKE)" --no-print-directory _help_format ;; \ *) echo "ERROR: Unknown format subcommand '$$subcommand'"; echo "Run 'make format help' for available options."; exit 1 ;; \ esac _format_default: $(call check_dev_setup) @echo "Formatting Python code..." - @$(PYTHON) -m ruff format src/python/ tests/ - @$(PYTHON) -m ruff check --fix src/python/ tests/ + @"$(PYTHON)" -m ruff format src/python/ tests/ + @"$(PYTHON)" -m ruff check --fix src/python/ tests/ @echo "OK: Python formatting complete" # Auto-fix Markdown lint violations @@ -419,26 +478,26 @@ test: exit 1; \ fi; \ case "$$subcommand" in \ - default) $(MAKE) --no-print-directory _test_default ;; \ - cov) $(MAKE) --no-print-directory _test_cov ;; \ - ts) $(MAKE) --no-print-directory _test_ts ;; \ - interop) $(MAKE) --no-print-directory _test_interop ;; \ - full) $(MAKE) --no-print-directory _test_all ;; \ - help) $(MAKE) --no-print-directory _help_test ;; \ + default) "$(MAKE)" --no-print-directory _test_default ;; \ + cov) "$(MAKE)" --no-print-directory _test_cov ;; \ + ts) "$(MAKE)" --no-print-directory _test_ts ;; \ + interop) "$(MAKE)" --no-print-directory _test_interop ;; \ + full) "$(MAKE)" --no-print-directory _test_all ;; \ + help) "$(MAKE)" --no-print-directory _help_test ;; \ *) echo "ERROR: Unknown test subcommand '$$subcommand'"; echo "Run 'make test help' for available options."; exit 1 ;; \ esac _test_default: $(call check_dev_setup) @echo "Running Python tests..." - @PYTHONPATH=src/python:$$PYTHONPATH $(PYTEST) tests/ -v + @PYTHONPATH="src/python$(PYTHONPATH_SEP)$$PYTHONPATH" $(PYTEST) tests/ -v @echo "OK: Python tests complete" # Run tests with coverage _test_cov: $(call check_dev_setup) @echo "Running Python tests with coverage..." - @PYTHONPATH=src/python:$$PYTHONPATH $(PYTEST) tests/ --cov=src/python/harbour --cov=src/python/credentials --cov-report=html --cov-report=term + @PYTHONPATH="src/python$(PYTHONPATH_SEP)$$PYTHONPATH" $(PYTEST) tests/ --cov=src/python/harbour --cov=src/python/credentials --cov-report=html --cov-report=term @echo "OK: Coverage run complete" # TypeScript targets @@ -451,31 +510,31 @@ build: exit 1; \ fi; \ case "$$subcommand" in \ - default|ts) $(MAKE) --no-print-directory _build_ts ;; \ - help) $(MAKE) --no-print-directory _help_build ;; \ + default|ts) "$(MAKE)" --no-print-directory _build_ts ;; \ + help) "$(MAKE)" --no-print-directory _help_build ;; \ *) echo "ERROR: Unknown build subcommand '$$subcommand'"; echo "Run 'make build help' for available options."; exit 1 ;; \ esac _build_ts: @echo "Building TypeScript..." - @cd $(TS_DIR) && corepack enable && yarn install && yarn build + @cd "$(TS_DIR)" && $(YARN) install && $(YARN) build @echo "OK: TypeScript build complete" _test_ts: @echo "Running TypeScript tests..." - @cd $(TS_DIR) && corepack enable && yarn install && yarn test + @cd "$(TS_DIR)" && $(YARN) install && $(YARN) test @echo "OK: TypeScript tests complete" _lint_ts: @echo "Linting TypeScript..." - @cd $(TS_DIR) && corepack enable && yarn install && yarn lint + @cd "$(TS_DIR)" && $(YARN) install && $(YARN) lint @echo "OK: TypeScript lint complete" # Cross-runtime interop tests (requires both Python + TypeScript) _test_interop: $(call check_dev_setup) @echo "Running cross-runtime interop tests..." - @PYTHONPATH=src/python:$$PYTHONPATH $(PYTEST) tests/interop/ -v + @PYTHONPATH="src/python$(PYTHONPATH_SEP)$$PYTHONPATH" $(PYTEST) tests/interop/ -v @echo "OK: Interop tests complete" story: @@ -487,10 +546,10 @@ story: exit 1; \ fi; \ case "$$subcommand" in \ - default) $(MAKE) --no-print-directory _story_default ;; \ - sign) $(MAKE) --no-print-directory _story_sign ;; \ - verify) $(MAKE) --no-print-directory _story_verify ;; \ - help) $(MAKE) --no-print-directory _help_story ;; \ + default) "$(MAKE)" --no-print-directory _story_default ;; \ + sign) "$(MAKE)" --no-print-directory _story_sign ;; \ + verify) "$(MAKE)" --no-print-directory _story_verify ;; \ + help) "$(MAKE)" --no-print-directory _help_story ;; \ *) echo "ERROR: Unknown story subcommand '$$subcommand'"; echo "Run 'make story help' for available options."; exit 1 ;; \ esac @@ -498,37 +557,56 @@ _story_sign: $(call check_dev_setup) @echo "Signing Harbour example storylines..." @rm -rf examples/signed examples/gaiax/signed - @PYTHONPATH=src/python:$$PYTHONPATH $(PYTHON) -m credentials.example_signer examples/ + @PYTHONPATH="src/python$(PYTHONPATH_SEP)$$PYTHONPATH" "$(PYTHON)" -m credentials.example_signer examples/ @echo "OK: Signed example artifacts written to ignored signed/ directories" _story_verify: $(call check_dev_setup) @echo "Verifying Harbour signed example storylines..." - @PYTHONPATH=src/python:$$PYTHONPATH $(PYTHON) -m credentials.verify_signed_examples + @PYTHONPATH="src/python$(PYTHONPATH_SEP)$$PYTHONPATH" "$(PYTHON)" -m credentials.verify_signed_examples @echo "OK: Signed Harbour example artifacts verified" _story_default: @echo "Running Harbour storyline (generate + sign + verify + SHACL validate)..." - @$(MAKE) --no-print-directory generate - @$(MAKE) --no-print-directory _story_sign - @$(MAKE) --no-print-directory _story_verify - @$(MAKE) --no-print-directory _validate_shacl + @"$(MAKE)" --no-print-directory generate + @"$(MAKE)" --no-print-directory _story_sign + @"$(MAKE)" --no-print-directory _story_verify + @"$(MAKE)" --no-print-directory _validate_shacl @echo "OK: Harbour storyline complete" +# ---------- Release Artifacts ---------- + +RELEASE_DIR ?= site/w3id/reachhaven/harbour + +release-artifacts: ## Copy artifacts to w3id directory structure for GitHub Pages publishing + @echo "Preparing w3id artifact structure..." + @mkdir -p "$(RELEASE_DIR)/core/v1" + @mkdir -p "$(RELEASE_DIR)/gx/v1" + @mkdir -p "$(RELEASE_DIR)/delegate/v1" + @cp artifacts/harbour-core-credential/harbour-core-credential.owl.ttl "$(RELEASE_DIR)/core/v1/ontology.ttl" + @cp artifacts/harbour-core-credential/harbour-core-credential.shacl.ttl "$(RELEASE_DIR)/core/v1/shapes.ttl" + @cp artifacts/harbour-core-credential/harbour-core-credential.context.jsonld "$(RELEASE_DIR)/core/v1/context.jsonld" + @cp artifacts/harbour-gx-credential/harbour-gx-credential.owl.ttl "$(RELEASE_DIR)/gx/v1/ontology.ttl" + @cp artifacts/harbour-gx-credential/harbour-gx-credential.shacl.ttl "$(RELEASE_DIR)/gx/v1/shapes.ttl" + @cp artifacts/harbour-gx-credential/harbour-gx-credential.context.jsonld "$(RELEASE_DIR)/gx/v1/context.jsonld" + @cp artifacts/harbour-core-delegation/harbour-core-delegation.owl.ttl "$(RELEASE_DIR)/delegate/v1/ontology.ttl" + @cp artifacts/harbour-core-delegation/harbour-core-delegation.context.jsonld "$(RELEASE_DIR)/delegate/v1/context.jsonld" + @echo "OK: Artifacts prepared in $(RELEASE_DIR)/" + # Compound targets all: @echo "Running default quality pipeline (lint + test)..." - @$(MAKE) --no-print-directory lint - @$(MAKE) --no-print-directory test + @"$(MAKE)" --no-print-directory lint + @"$(MAKE)" --no-print-directory test @echo "OK: Default quality pipeline complete" # Run all tests (Python + TypeScript) _test_all: @echo "Running all tests (Python + SHACL + TypeScript)..." - @$(MAKE) --no-print-directory _build_ts - @$(MAKE) --no-print-directory _test_default - @$(MAKE) --no-print-directory _validate_shacl - @$(MAKE) --no-print-directory _test_ts + @"$(MAKE)" --no-print-directory _build_ts + @"$(MAKE)" --no-print-directory _test_default + @"$(MAKE)" --no-print-directory _validate_shacl + @"$(MAKE)" --no-print-directory _test_ts @echo "OK: All tests complete" # Clean generated files diff --git a/README.md b/README.md index 1d9e96f..bc72005 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,16 @@ Or for development: git clone --recurse-submodules https://github.com/reachhaven/harbour-credentials.git cd harbour-credentials make setup +# PowerShell +.\.venv\Scripts\Activate.ps1 + +# macOS / Linux / Git Bash source .venv/bin/activate ``` > **Note:** The `--recurse-submodules` flag is required to clone the ontology-management-base and w3id.org submodules. > -> `make setup` installs Python dev dependencies (`.[dev]`), LinkML, pre-commit hooks, and bootstraps TypeScript dependencies (`corepack enable` + `yarn install` in `src/typescript/harbour`). +> `make setup` installs Python dev dependencies (`.[dev]`), LinkML, pre-commit hooks, and bootstraps TypeScript dependencies with `corepack yarn install` in `src/typescript/harbour`. > Use `make install dev` only if you need to refresh an existing Python environment. If you already cloned without submodules: @@ -47,7 +51,7 @@ git submodule update --init --recursive --depth 1 ```bash # If you already ran `make setup`, TypeScript dependencies are already bootstrapped. # Otherwise: -make ts-bootstrap +make setup ts ``` ## Quick Start diff --git a/docs/contributing.md b/docs/contributing.md index a5a2163..e23ea8e 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -15,6 +15,11 @@ Thank you for your interest in contributing to Harbour Credentials! ```bash make setup + + # PowerShell + .\.venv\Scripts\Activate.ps1 + + # macOS / Linux / Git Bash source .venv/bin/activate ``` diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 6b75781..f9d59bb 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -15,7 +15,12 @@ git clone https://github.com/ASCS-eV/harbour-credentials.git cd harbour-credentials # Create virtual environment -python3 -m venv .venv +python -m venv .venv + +# PowerShell +.\.venv\Scripts\Activate.ps1 + +# macOS / Linux / Git Bash source .venv/bin/activate # Install with dev dependencies @@ -43,8 +48,8 @@ npm install @reachhaven/harbour-credentials git clone https://github.com/ASCS-eV/harbour-credentials.git cd harbour-credentials/src/typescript/harbour -npm install -npm run build +corepack yarn install +corepack yarn build ``` ## Verify Installation diff --git a/linkml/harbour-core-credential.yaml b/linkml/harbour-core-credential.yaml index c5b505c..42e0697 100644 --- a/linkml/harbour-core-credential.yaml +++ b/linkml/harbour-core-credential.yaml @@ -1,5 +1,5 @@ id: https://w3id.org/reachhaven/harbour/core/v1 -name: harbour +name: harbour-core-credential description: > Base LinkML schema for Harbour credentials. Defines the W3C VC envelope constraints (issuer, validFrom, @@ -40,22 +40,30 @@ description: > # OWL ontologies — see [LINKML-1950] (closed: wrapping is the pattern). # https://github.com/linkml/linkml/issues/1950 # -# Generator workarounds (in generate_artifacts.py): -# - cred:issuer nodeKind patched to sh:IRIOrLiteral because LinkML maps -# range: string to sh:Literal, but [VC-CTX] defines issuer as @type: @id. -# - sh:class linkml:Any removed — see [LINKML-2914] (open). -# https://github.com/linkml/linkml/issues/2914 -# - Imported cred: terms excluded from JSON-LD context to avoid redefining -# @protected terms already provided by [VC-CTX]. +# Generator flags (in generate_artifacts.py): +# - xsd_anyuri_as_iri=True — maps range: uri slots to @type: @id instead of +# xsd:anyURI in JSON-LD (upstream PR linkml/linkml#3292, merged). +# - exclude_imports=True — emits SHACL shapes only for the current schema's +# own classes, not imported ones (upstream PR linkml/linkml#3294, merged). +# +# Remaining manual patches: # - "type": "@type" manually injected — LinkML cannot emit this alias # without declaring a "type" slot that would conflict with [VC-CTX]'s # @protected definition of "type". +# - OWL owl:equivalentClass axioms patched in for Gaia-X alignment +# (HarbourLegalPerson ≡ gx:LegalPerson, etc.) — domain logic, not +# a generator limitation. +# +# Previously required workarounds now fixed upstream: +# - sh:class linkml:Any removal — fixed by linkml/linkml#3278 (merged). +# - issuer/holder sh:nodeKind — fixed by linkml/linkml#3291 (merged). +# - Imported cred: terms in JSON-LD context — fixed by linkml/linkml#3279. +# - uses_schemaloader bypass — fixed by linkml/linkml#3293 (merged). # ============================================================================ prefixes: linkml: https://w3id.org/linkml/ harbour: https://w3id.org/reachhaven/harbour/core/v1/ - harbour.delegate: https://w3id.org/reachhaven/harbour/delegate/v1/ sec: https://w3id.org/security# sdo: http://schema.org/ xsd: http://www.w3.org/2001/XMLSchema# @@ -105,19 +113,19 @@ slots: # In practice always a DID (did:ethr:..., did:key:..., etc.). controller: slot_uri: sec:controller - range: Any + range: uri # [DID-CORE JSON-LD] did/v1 expands authentication to the security vocabulary # and represents values as IRI references to verification methods. authentication: slot_uri: sec:authenticationMethod - range: Any + range: uri # [DID-CORE JSON-LD] did/v1 expands assertionMethod to the security # vocabulary and represents values as IRI references to verification methods. assertionMethod: slot_uri: sec:assertionMethod - range: Any + range: uri # [DID-CORE] §5.3.1 — publicKeyJwk carries verification method key material # encoded as a JSON Web Key (RFC 7517). In JSON-LD it is represented as @@ -228,9 +236,6 @@ slots: required: false classes: - Any: - class_uri: linkml:Any - description: "Generic class for any type of value." # ========================================== # 1. ROOT DOCUMENT @@ -245,25 +250,26 @@ classes: authentication: slot_uri: sec:authenticationMethod multivalued: true - range: Any + range: uri assertionMethod: slot_uri: sec:assertionMethod multivalued: true - range: Any + range: uri # [DID-CORE] §5.4 — service is OPTIONAL, each entry MUST have id, type, # and serviceEndpoint. service values MUST be unique. service: slot_uri: didcore:service multivalued: true inlined: true - range: Any + range: ServiceUnion # [DID-CORE] §5.3.1 — verificationMethod entries MUST have id, type, # controller, and key material (publicKeyJwk or publicKeyMultibase). # Harbour models a subset (id, controller, blockchainAccountId). verificationMethod: slot_uri: sec:verificationMethod multivalued: true - range: Any + inlined: true + range: VerificationMethod # ========================================== # 2. SERVICES @@ -295,7 +301,7 @@ classes: - serviceEndpoint slot_usage: serviceEndpoint: - range: Any + range: uri description: > HTTPS URL where the self-signed credential (VC-JOSE-COSE JWT) can be fetched. In DID JSON-LD this is commonly emitted as an IRI node @@ -394,7 +400,10 @@ classes: required: false credentialSubject: range: Any - required: false + required: true + description: > + VCDM2 §4.6 — MUST be present. Domain schemas narrow the range + to a typed subject class (e.g. HarbourLegalPerson). evidence: range: Evidence required: false diff --git a/linkml/harbour-core-delegation.yaml b/linkml/harbour-core-delegation.yaml index e08808c..442ee99 100644 --- a/linkml/harbour-core-delegation.yaml +++ b/linkml/harbour-core-delegation.yaml @@ -1,5 +1,5 @@ id: https://w3id.org/reachhaven/harbour/delegate/v1 -name: harbour-delegate +name: harbour-core-delegation description: > Delegation transaction types for the Harbour signing flow. Defines typed transaction data objects used in OID4VP-aligned delegated diff --git a/linkml/harbour-gx-credential.yaml b/linkml/harbour-gx-credential.yaml index e099ce8..3bf7afd 100644 --- a/linkml/harbour-gx-credential.yaml +++ b/linkml/harbour-gx-credential.yaml @@ -92,6 +92,8 @@ prefixes: harbour.gx: https://w3id.org/reachhaven/harbour/gx/v1/ sdo: http://schema.org/ xsd: http://www.w3.org/2001/XMLSchema# + # cred is needed for SHACL prefix binding of inherited W3C VC envelope + # slots (issuer, validFrom, validUntil, credentialStatus, evidence). cred: https://www.w3.org/2018/credentials# gx: https://w3id.org/gaia-x/development# @@ -116,9 +118,10 @@ slots: # participant types in the Gaia-X hierarchy. participant: description: > - Reference to a Gaia-X participant node (harbour:LegalPerson or - harbour:NaturalPerson). Used by downstream credential layers - (e.g. simpulseid) to embed participant data in credential subjects. + Embedded or referenced Gaia-X participant node (harbour:LegalPerson + or harbour:NaturalPerson). Downstream credential layers (e.g. + simpulseid) inline participant data as a blank node in the + credentialSubject, or reference it via IRI. slot_uri: harbour.gx:participant range: Participant required: false @@ -301,7 +304,7 @@ classes: Valid values: SC (Standard Compliance), L1, L2, L3. See [GX-CD-LABEL] §10 for machine-readable label format. slot_uri: harbour.gx:labelLevel - range: string + range: LabelLevel required: true # [GX-CD-LABEL] §10 — Compliance Service version (software version # of the GXDCH instance that performed validation). @@ -363,6 +366,13 @@ classes: required: true # [SRI] W3C Subresource Integrity — format: "sha256-{hex_digest}". # https://www.w3.org/TR/SRI/ + # NOTE: Semantically this is a cred:sriString (Gaia-X SriString type), + # but we keep range: string because (1) the JSON-LD context cannot + # express class-scoped @type coercion (LinkML contexts are per-slot, + # not per-class+slot), and (2) examples use prefixed keys + # ("harbour.gx:digestSRI") which bypass context @type coercion, + # producing xsd:string — mismatching the sh:datatype cred:sriString + # that SriString would generate in SHACL. digestSRI: description: > Subresource Integrity [SRI] hash of the verifiable credential. @@ -385,7 +395,6 @@ classes: slot_uri: harbour.gx:embeddedCredential range: string required: false - inlined: true # ------------------------------------------ # 2c. NATURAL PERSON (harbour extension) @@ -394,7 +403,8 @@ classes: # no NaturalPerson type, so Harbour creates one as a sibling of # gx:LegalPerson [GX-ONT-LP] under gx:Participant. # [SCHEMA-ORG] — person-specific attributes (givenName, familyName, - # memberOf, email) use http://schema.org/ vocabulary. + # memberOf) use http://schema.org/ vocabulary. + # [GX-ONT] — email uses gx:email from the Gaia-X vocabulary. HarbourNaturalPerson: is_a: Participant @@ -404,19 +414,24 @@ classes: NaturalPerson type, so Harbour creates one as a sibling of gx:LegalPerson [GX-ONT-LP]. Inherits name, description from gx:Participant. Adds person-specific attributes from [SCHEMA-ORG] - (givenName, familyName, email, memberOf) and gx:address. + (givenName, familyName, memberOf) and gx:address. + Email uses gx:email from the Gaia-X vocabulary. class_uri: harbour.gx:NaturalPerson slots: # [GX-ONT-PA] — gx:address (vcard:Address) from gx:Participant. - address - # [SCHEMA-ORG] — http://schema.org/email - - email slot_usage: - email: - slot_uri: sdo:email address: slot_uri: gx:address attributes: + # [GX-ONT] — gx:email from the Gaia-X contact vocabulary. + # Defined as a local attribute (not inherited slot) so the + # harbour-gx JSON-LD context includes the gx:email mapping, + # overriding harbour-core's sdo:email (from ContactPoint). + email: + description: Email address of the contact. + slot_uri: gx:email + range: string # [SCHEMA-ORG] — http://schema.org/givenName givenName: description: First name / given name of the natural person. @@ -428,6 +443,9 @@ classes: slot_uri: sdo:familyName range: string # [SCHEMA-ORG] — http://schema.org/memberOf + # Cross-document reference — the target LegalPerson is in another + # credential. range: uri gives sh:nodeKind sh:IRI (SHACL) and + # @type: @id (JSON-LD via xsd_anyuri_as_iri flag). memberOf: description: Organization (LegalPerson) the natural person belongs to. slot_uri: sdo:memberOf diff --git a/linkml/w3c-vc.yaml b/linkml/w3c-vc.yaml index 1dc1552..5793398 100644 --- a/linkml/w3c-vc.yaml +++ b/linkml/w3c-vc.yaml @@ -3,8 +3,9 @@ name: w3c-vc description: > LinkML projection of W3C Verifiable Credentials Data Model v2.0 vocabulary terms. Defines the VC envelope properties (issuer, validFrom, validUntil, - evidence, credentialStatus) so they can be imported and constrained by - downstream schemas via slot_usage without redefining them. + evidence, credentialStatus, credentialSubject, holder, verifiableCredential) + so they can be imported and constrained by downstream schemas via slot_usage + without redefining them. The JSON-LD context for these terms is provided by the W3C VC v2 context (https://www.w3.org/ns/credentials/v2), NOT by this schema. @@ -30,6 +31,22 @@ description: > # - Downstream schemas (e.g. harbour-core-credential.yaml) refine constraints # via slot_usage to create stricter profiles # - default_prefix: cred — schema ID matches the external vocabulary namespace +# +# RANGE SELECTION +# ============================================================================ +# [VC-CTX] maps issuer and holder with @type: @id — they are always IRI +# nodes in RDF (even the "issuer as object" form resolves to an @id node). +# LinkML range: uri produces sh:nodeKind sh:IRI in SHACL — correct. +# The JSON-LD @type: xsd:anyURI that LinkML would generate is irrelevant +# because HarbourContextGenerator excludes these imported slots; the W3C +# VC v2 context provides @type: @id at runtime. +# See https://github.com/linkml/linkml/pull/3292 for the uri/context fix. +# +# evidence, credentialStatus, credentialSubject, and verifiableCredential +# are object-valued in [VC-CTX] (@type: @id for each). Using range: Any +# (class_uri: linkml:Any) produces @type: @id in context and no SHACL +# constraint — correct for the loose base spec. Downstream slot_usage +# narrows to concrete classes (Evidence, CRSetEntry, etc.). # ============================================================================ prefixes: @@ -44,15 +61,20 @@ default_range: string imports: - linkml:types +classes: + # Needed so that downstream schemas can reference linkml:Any via range. + Any: + class_uri: linkml:Any + slots: # [VCDM2] §4.7 — A verifiable credential MUST have an issuer property. # The value MUST be either a URL or an object containing an id property - # whose value is a URL. Modeled as range: string here because LinkML has - # no native IRI type; the SHACL generator is patched downstream to emit - # sh:nodeKind sh:IRIOrLiteral (see HarbourShaclGenerator). + # whose value is a URL. In both cases the RDF value is an IRI node + # ([VC-CTX] maps issuer with @type: @id). + # range: uri → SHACL sh:nodeKind sh:IRI (correct). issuer: slot_uri: cred:issuer - range: string + range: uri description: > [VCDM2] §4.7 — issuer MUST exist; value MUST be a URL or object with id. @@ -78,8 +100,12 @@ slots: # [VCDM2] §5.6 — The evidence property provides information about the # process and/or evidence the issuer used when evaluating the claims # made in the credential. Each evidence object MUST specify its type. + # [VC-CTX] maps evidence with @type: @id. + # range: Any produces @type: @id in context and no SHACL constraint; + # downstream slot_usage narrows to Evidence class. evidence: slot_uri: cred:evidence + range: Any multivalued: true description: > [VCDM2] §5.6 — evidence is OPTIONAL (0..*), each object MUST have type. @@ -88,8 +114,10 @@ slots: # to discover information about the current status of a verifiable # credential (e.g. whether it is suspended or revoked). Each status # entry MUST specify its id and type. + # [VC-CTX] maps credentialStatus with @type: @id. credentialStatus: slot_uri: cred:credentialStatus + range: Any multivalued: true description: > [VCDM2] §4.10 — credentialStatus is OPTIONAL (0..*), each MUST have @@ -99,8 +127,10 @@ slots: # credentialSubject property. The value is a set of objects containing # claims about the subject(s) of the credential. Each subject MAY # have an id property. + # [VC-CTX] maps credentialSubject with @type: @id. credentialSubject: slot_uri: cred:credentialSubject + range: Any multivalued: true description: > [VCDM2] §4.6 — credentialSubject is REQUIRED (1..*), each MAY have id. @@ -108,16 +138,20 @@ slots: # [VCDM2] §6.1 — A verifiable presentation MUST have a holder property. # The value MUST be a URL (typically a DID) identifying the entity # presenting the credentials. + # [VC-CTX] maps holder with @type: @id. + # range: uri → SHACL sh:nodeKind sh:IRI (correct). holder: slot_uri: cred:holder - range: string + range: uri description: > [VCDM2] §6.1 — holder MUST exist on a VP; value MUST be a URL (DID). # [VCDM2] §6.1 — verifiableCredential contains the VCs being presented. # Each entry is a VerifiableCredential or an enveloped credential. + # [VC-CTX] maps verifiableCredential with @type: @id, @container: @graph. verifiableCredential: slot_uri: cred:verifiableCredential + range: Any multivalued: true description: > [VCDM2] §6.1 — verifiableCredential is REQUIRED (1..*) on a VP. diff --git a/src/python/harbour/generate_artifacts.py b/src/python/harbour/generate_artifacts.py index cb0bb42..915a199 100644 --- a/src/python/harbour/generate_artifacts.py +++ b/src/python/harbour/generate_artifacts.py @@ -2,25 +2,24 @@ """Generate downstream artifacts (OWL ontology, SHACL shapes, JSON-LD context) from Harbour LinkML schemas. -The custom HarbourShaclGenerator fixes the ``cred:issuer`` property shape: -LinkML maps ``range: string`` to ``sh:nodeKind sh:Literal``, but the W3C VC v2 -context defines ``issuer`` with ``@type: @id``, so the RDF value is an IRI. -The generator patches the property shape to ``sh:nodeKind sh:IRIOrLiteral`` -(accepting both IRIs from JSON-LD and literal strings from plain JSON). - -The custom HarbourContextGenerator excludes terms imported from external -vocabularies (e.g. W3C VC v2) so the generated JSON-LD context does not -redefine ``@protected`` terms already provided by the W3C VC v2 context. +Uses upstream ``ShaclGenerator`` (with ``uses_schemaloader=False`` and importmap +passthrough, linkml/linkml#2913 fixed in ASCS-eV/linkml PR #3293) and +``ContextGenerator`` (with ``mergeimports=False`` to skip external vocabulary +terms, ASCS-eV/linkml PR #3279) so harbour's JSON-LD context does not redefine +``@protected`` terms already provided by the W3C VC v2 context. + +The ``xsd_anyuri_as_iri=True`` flag (ASCS-eV/linkml PR #3292) ensures +``range: uri`` slots produce ``@type: @id`` in the context, matching the SHACL +``sh:nodeKind sh:IRI`` constraint. """ import json from pathlib import Path -from linkml.generators.jsonldcontextgen import ContextGenerator as _BaseContextGenerator +from linkml.generators.jsonldcontextgen import ContextGenerator from linkml.generators.owlgen import OwlSchemaGenerator -from linkml.generators.shaclgen import ShaclGenerator as _BaseShaclGenerator -from linkml_runtime.linkml_model.meta import SlotDefinition -from rdflib import OWL, Namespace, URIRef +from linkml.generators.shaclgen import ShaclGenerator +from rdflib import RDFS, OWL, URIRef REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent LINKML_DIR = REPO_ROOT / "linkml" @@ -41,71 +40,6 @@ # with the canonical JSON used for hashing. SHACL_SKIP_DOMAINS = {"harbour-core-delegation"} -SH = Namespace("http://www.w3.org/ns/shacl#") -XSD = Namespace("http://www.w3.org/2001/XMLSchema#") -CRED = Namespace("https://www.w3.org/2018/credentials#") -LINKML = Namespace("https://w3id.org/linkml/") - - -class HarbourShaclGenerator(_BaseShaclGenerator): - """SHACL generator with importmap-aware initialisation and IRI fixes. - - Bypasses ``ShaclGenerator.__post_init__``'s ``SchemaView`` construction - which ignores ``importmap`` / ``base_dir``, causing cross-directory - imports to fail. - See https://github.com/linkml/linkml/issues/2913 - - Also corrects ``cred:issuer`` property shape (IRI, not Literal) and - removes ``sh:class linkml:Any`` constraints. - See https://github.com/linkml/linkml/issues/2914 - """ - - uses_schemaloader = False - - def __post_init__(self) -> None: - from linkml.utils.generator import Generator - - Generator.__post_init__(self) - self.generate_header() - - def as_graph(self): - g = super().as_graph() - # Fix cred:issuer nodeKind (IRI, not Literal) - for ps in g.subjects(SH.path, CRED.issuer): - g.remove((ps, SH.nodeKind, SH.Literal)) - g.add((ps, SH.nodeKind, SH.IRIOrLiteral)) - for dt in list(g.objects(ps, SH.datatype)): - g.remove((ps, SH.datatype, dt)) - # Fix cred:holder nodeKind (IRI, not Literal) — DIDs are IRIs - for ps in g.subjects(SH.path, CRED.holder): - g.remove((ps, SH.nodeKind, SH.Literal)) - g.add((ps, SH.nodeKind, SH.IRIOrLiteral)) - for dt in list(g.objects(ps, SH.datatype)): - g.remove((ps, SH.datatype, dt)) - # Remove sh:class linkml:Any — meta-type not present in instance data - for s, p, o in list(g.triples((None, SH["class"], LINKML.Any))): - g.remove((s, p, o)) - return g - - -class HarbourContextGenerator(_BaseContextGenerator): - """Context generator that excludes imported vocabulary terms. - - W3C VC v2 envelope terms (issuer, validFrom, validUntil, evidence, - credentialStatus) are defined in ``w3c-vc.yaml`` and imported into - harbour schemas. With ``mergeimports=False`` these slots are marked - with ``imported_from``. This generator skips them so the harbour - JSON-LD context does not redefine ``@protected`` terms already - provided by ``https://www.w3.org/ns/credentials/v2``. - """ - - def visit_slot(self, aliased_slot_name: str, slot: SlotDefinition) -> None: - if getattr(slot, "imported_from", None) and not str( - slot.imported_from - ).startswith("linkml"): - return - super().visit_slot(aliased_slot_name, slot) - def main() -> None: importmap_path = LINKML_DIR / "importmap.json" @@ -144,15 +78,19 @@ def main() -> None: (out_dir / f"{domain}.owl.ttl").write_text(owl_text, encoding="utf-8") if domain not in SHACL_SKIP_DOMAINS: - shacl_gen = HarbourShaclGenerator( - schema, importmap=importmap, base_dir=base_dir + shacl_gen = ShaclGenerator( + schema, importmap=importmap, base_dir=base_dir, ) (out_dir / f"{domain}.shacl.ttl").write_text( shacl_gen.serialize(), encoding="utf-8" ) - ctx_gen = HarbourContextGenerator( - schema, mergeimports=False, importmap=importmap, base_dir=base_dir + ctx_gen = ContextGenerator( + schema, + mergeimports=False, + xsd_anyuri_as_iri=True, + importmap=importmap, + base_dir=base_dir, ) ctx_text = ctx_gen.serialize() @@ -163,7 +101,8 @@ def main() -> None: if isinstance(ctx_obj, dict) and "type" not in ctx_obj: ctx_obj["type"] = "@type" ctx_data["@context"] = ctx_obj - ctx_text = json.dumps(ctx_data, indent=3, ensure_ascii=False) + + ctx_text = json.dumps(ctx_data, indent=3, ensure_ascii=False) (out_dir / f"{domain}.context.jsonld").write_text(ctx_text, encoding="utf-8") @@ -184,7 +123,7 @@ def _patch_owl_equivalences(owl_gen: OwlSchemaGenerator, owl_text: str) -> str: inference (which doesn't understand owl:equivalentClass) can resolve the type hierarchy via class_uri URIs used in instance data. """ - from rdflib import RDFS, Graph + from rdflib import Graph sv = owl_gen.schemaview schema = sv.schema diff --git a/submodules/w3id.org b/submodules/w3id.org index 5351f81..ac4b788 160000 --- a/submodules/w3id.org +++ b/submodules/w3id.org @@ -1 +1 @@ -Subproject commit 5351f818b76f1ce45de93dec3d0e33466b06dd85 +Subproject commit ac4b788c2ab3187cb0210d15a67ebf3d7b765c68 From 42fbe1761a0f47de5f80d1bbb71dcd80cb35b866 Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 20 Mar 2026 23:55:14 +0100 Subject: [PATCH 39/78] chore: update OMB pin to include covering axiom bugfix Update ontology-management-base submodule to 3568a7a which includes regenerated GX artifacts with the OWL covering axiom fix (linkml#3309). Signed-off-by: jdsika --- submodules/ontology-management-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index 6713639..3568a7a 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit 6713639deb37c84f6b6b1bce3f54af9d4d907534 +Subproject commit 3568a7a29ef2637f39a446b58c04accfe94f1828 From 943917a1c781ea9720206de19608f330f1a9cf51 Mon Sep 17 00:00:00 2001 From: jdsika Date: Sat, 21 Mar 2026 09:05:29 +0100 Subject: [PATCH 40/78] chore: update OMB pin with HTTPS schema.org GX artifacts Update OMB submodule to 98182c9 which regenerates GX artifacts with https://schema.org/ namespace, resolving SHACL closed-shape validation failures on schema:name. Signed-off-by: jdsika --- submodules/ontology-management-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index 3568a7a..98182c9 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit 3568a7a29ef2637f39a446b58c04accfe94f1828 +Subproject commit 98182c9e60715657363a86e7fdb15de6a337e8a2 From b95af9fa35ba0e7fead56a9603e76ec83dbe3a95 Mon Sep 17 00:00:00 2001 From: jdsika Date: Sat, 21 Mar 2026 09:08:20 +0100 Subject: [PATCH 41/78] fix(examples): use https://schema.org/ in GX example contexts Align example JSON-LD contexts with the HTTPS schema.org namespace used by the regenerated GX SHACL shapes. Signed-off-by: jdsika --- examples/gaiax/gx-legal-person.json | 2 +- examples/gaiax/legal-person-credential-embedded.json | 2 +- examples/gaiax/participant-vp.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/gaiax/gx-legal-person.json b/examples/gaiax/gx-legal-person.json index 10786d1..79f50e2 100644 --- a/examples/gaiax/gx-legal-person.json +++ b/examples/gaiax/gx-legal-person.json @@ -4,7 +4,7 @@ "https://w3id.org/gaia-x/development#", { "vcard": "http://www.w3.org/2006/vcard/ns#", - "schema": "http://schema.org/" + "schema": "https://schema.org/" } ], "type": [ diff --git a/examples/gaiax/legal-person-credential-embedded.json b/examples/gaiax/legal-person-credential-embedded.json index 4af4d68..a83f0a9 100644 --- a/examples/gaiax/legal-person-credential-embedded.json +++ b/examples/gaiax/legal-person-credential-embedded.json @@ -20,7 +20,7 @@ "type": "harbour.gx:CompliantCredentialReference", "harbour.gx:credentialType": "gx:LegalPerson", "harbour.gx:digestSRI": "sha256-a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2", - "harbour.gx:embeddedCredential": "{\"@context\":[\"https://www.w3.org/ns/credentials/v2\",\"https://w3id.org/gaia-x/development#\",{\"vcard\":\"http://www.w3.org/2006/vcard/ns#\",\"schema\":\"http://schema.org/\"}],\"type\":[\"VerifiableCredential\"],\"id\":\"urn:uuid:e2e3e4e5-e6e7-e8e9-e0e1-e2e3e4e5e6e7\",\"issuer\":\"did:ethr:0x14a34:0xa2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1\",\"validFrom\":\"2024-01-15T00:00:00Z\",\"validUntil\":\"2025-01-15T00:00:00Z\",\"credentialSubject\":{\"type\":\"gx:LegalPerson\",\"id\":\"urn:uuid:e2e3e4e5-e6e7-e8e9-e0e1-e2e3e4e5e6e7#cs\",\"schema:name\":\"Example Corporation GmbH\",\"gx:registrationNumber\":{\"id\":\"urn:uuid:e3e4e5e6-e7e8-e9e0-e1e2-e3e4e5e6e7e8#cs\"},\"gx:headquartersAddress\":{\"type\":\"gx:Address\",\"gx:countryCode\":\"DE\",\"vcard:street-address\":\"Musterstra\\u00dfe 42\",\"vcard:locality\":\"M\\u00fcnchen\",\"vcard:postal-code\":\"80331\"},\"gx:legalAddress\":{\"type\":\"gx:Address\",\"gx:countryCode\":\"DE\",\"vcard:street-address\":\"Musterstra\\u00dfe 42\",\"vcard:locality\":\"M\\u00fcnchen\",\"vcard:postal-code\":\"80331\"}}}", + "harbour.gx:embeddedCredential": "{\"@context\":[\"https://www.w3.org/ns/credentials/v2\",\"https://w3id.org/gaia-x/development#\",{\"vcard\":\"http://www.w3.org/2006/vcard/ns#\",\"schema\":\"https://schema.org/\"}],\"type\":[\"VerifiableCredential\"],\"id\":\"urn:uuid:e2e3e4e5-e6e7-e8e9-e0e1-e2e3e4e5e6e7\",\"issuer\":\"did:ethr:0x14a34:0xa2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1\",\"validFrom\":\"2024-01-15T00:00:00Z\",\"validUntil\":\"2025-01-15T00:00:00Z\",\"credentialSubject\":{\"type\":\"gx:LegalPerson\",\"id\":\"urn:uuid:e2e3e4e5-e6e7-e8e9-e0e1-e2e3e4e5e6e7#cs\",\"schema:name\":\"Example Corporation GmbH\",\"gx:registrationNumber\":{\"id\":\"urn:uuid:e3e4e5e6-e7e8-e9e0-e1e2-e3e4e5e6e7e8#cs\"},\"gx:headquartersAddress\":{\"type\":\"gx:Address\",\"gx:countryCode\":\"DE\",\"vcard:street-address\":\"Musterstra\\u00dfe 42\",\"vcard:locality\":\"M\\u00fcnchen\",\"vcard:postal-code\":\"80331\"},\"gx:legalAddress\":{\"type\":\"gx:Address\",\"gx:countryCode\":\"DE\",\"vcard:street-address\":\"Musterstra\\u00dfe 42\",\"vcard:locality\":\"M\\u00fcnchen\",\"vcard:postal-code\":\"80331\"}}}", "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantLegalPersonVC" }, "harbour.gx:compliantRegistrationVC": { diff --git a/examples/gaiax/participant-vp.json b/examples/gaiax/participant-vp.json index 4f3baa9..e1a2670 100644 --- a/examples/gaiax/participant-vp.json +++ b/examples/gaiax/participant-vp.json @@ -17,7 +17,7 @@ "https://w3id.org/gaia-x/development#", { "vcard": "http://www.w3.org/2006/vcard/ns#", - "schema": "http://schema.org/" + "schema": "https://schema.org/" } ], "type": [ From 2b8b732261e8c02cada4468c29180c6c9ca42dcb Mon Sep 17 00:00:00 2001 From: jdsika Date: Thu, 26 Mar 2026 09:37:15 +0100 Subject: [PATCH 42/78] feat: add deterministic flag and xsd-anyuri-as-iri to harbour generators - Add deterministic=True to OWL, SHACL, and context generators - Add exclude_external_imports=True to context generator - Regenerate all harbour artifacts with improved flags - Update OMB submodule pin with regenerated artifacts - Update w3id.org submodule pin - Refactor sd_jwt module and add tests - Remove deprecated claim_mapping module Signed-off-by: jdsika --- CLAUDE.md | 2 +- README.md | 1 - docs/specs/references/README.md | 4 + .../references/token-status-list-draft-19.txt | 4368 +++++++++++++++++ docs/specs/references/token-status-list.md | 161 + examples/README.md | 17 +- .../did-ethr/harbour-signing-service.did.json | 2 +- ...oire Participant Credential VP_(2026).json | 2 +- linkml/harbour-core-credential.yaml | 6 +- linkml/harbour-gx-credential.yaml | 13 +- pyproject.toml | 1 - src/python/credentials/__init__.py | 19 +- src/python/credentials/claim_mapping.py | 446 -- src/python/harbour/generate_artifacts.py | 8 +- src/python/harbour/sd_jwt.py | 207 +- submodules/ontology-management-base | 2 +- submodules/w3id.org | 2 +- .../python/credentials/test_claim_mapping.py | 194 - tests/python/harbour/test_sd_jwt.py | 174 + tests/python/harbour/test_sd_jwt_vp.py | 6 +- 20 files changed, 4903 insertions(+), 732 deletions(-) create mode 100644 docs/specs/references/token-status-list-draft-19.txt create mode 100644 docs/specs/references/token-status-list.md delete mode 100644 src/python/credentials/claim_mapping.py delete mode 100644 tests/python/credentials/test_claim_mapping.py diff --git a/CLAUDE.md b/CLAUDE.md index 6249db7..225323b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,7 +130,7 @@ import { ## CLI Entry Points -All Python modules have CLI interfaces: `python -m harbour.keys --help`, `python -m harbour.signer --help`, etc. Also: `python -m credentials.claim_mapping --help`, `python -m credentials.example_signer --help`. +All Python modules have CLI interfaces: `python -m harbour.keys --help`, `python -m harbour.signer --help`, etc. Also: `python -m credentials.example_signer --help`. ## Coding Conventions diff --git a/README.md b/README.md index bc72005..2152f44 100644 --- a/README.md +++ b/README.md @@ -214,7 +214,6 @@ src/ │ │ ├── kb_jwt.py # Key Binding JWT │ │ └── x509.py # X.509 certificates │ └── credentials/ # Credential processing pipeline -│ ├── claim_mapping.py │ └── example_signer.py └── typescript/ └── harbour/ # Crypto library (feature parity) diff --git a/docs/specs/references/README.md b/docs/specs/references/README.md index e319bf0..dbe7b8a 100644 --- a/docs/specs/references/README.md +++ b/docs/specs/references/README.md @@ -17,6 +17,8 @@ They are copies of specifications published by their respective standards organi | `vc-jose-cose.md` | [VC-JOSE-COSE](https://www.w3.org/TR/vc-jose-cose/) | W3C | [W3C Document License](https://www.w3.org/copyright/document-license-2023/) | | `sd-jwt-rfc9901.md` | [RFC 9901: SD-JWT](https://www.rfc-editor.org/rfc/rfc9901) | IETF | [IETF Trust](https://trustee.ietf.org/license-info) | | `sd-jwt-vc.md` | [SD-JWT-VC draft-15](https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/) | IETF | [IETF Trust](https://trustee.ietf.org/license-info) | +| `token-status-list.md` | [Token Status List draft-19](https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/) | IETF | [IETF Trust](https://trustee.ietf.org/license-info) | +| `token-status-list-draft-19.txt` | Raw full spec text (78 pages) — retained for search | IETF | [IETF Trust](https://trustee.ietf.org/license-info) | | `oid4vp-1.0.md` | [OpenID4VP 1.0](https://openid.net/specs/openid-4-verifiable-presentations-1_0.html) | OpenID Foundation | [OpenID IPR](https://openid.net/intellectual-property/) | | `oid4vp-1.0.txt` | Raw full spec text (3,834 lines) — retained for search | OpenID Foundation | [OpenID IPR](https://openid.net/intellectual-property/) | | `gx-architecture-document-25.11.md` | [Gaia-X AD 25.11](https://docs.gaia-x.eu/technical-committee/architecture-document/25.11/) | Gaia-X AISBL | CC BY-NC-ND 4.0 | @@ -31,6 +33,7 @@ They are copies of specifications published by their respective standards organi - `oid4vp-1.0.txt`, `did-webs-spec.md`, `keri-draft.md`: **2026-02-24** - `vc-jose-cose.md`, `sd-jwt-vc.md`, `csc-data-model.md`: **2026-02-25** - `oid4vp-1.0.md`, `vc-data-model-2.0.md`, `did-core.md`, `sd-jwt-rfc9901.md`, `gx-architecture-document-25.11.md`: **2026-03-10** +- `token-status-list.md`, `token-status-list-draft-19.txt`: **2026-03-20** ## Usage @@ -66,6 +69,7 @@ Always refer to the original sources for the most up-to-date and legally binding - **W3C VC-JOSE-COSE**: https://www.w3.org/TR/vc-jose-cose/ - **SD-JWT (RFC 9901)**: https://www.rfc-editor.org/rfc/rfc9901 - **SD-JWT-VC**: https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/ +- **Token Status List**: https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/ - **OpenID4VP**: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html - **Gaia-X Architecture**: https://docs.gaia-x.eu/technical-committee/architecture-document/25.11/ - **did:ethr**: https://github.com/decentralized-identity/ethr-did-resolver/blob/master/doc/did-method-spec.md diff --git a/docs/specs/references/token-status-list-draft-19.txt b/docs/specs/references/token-status-list-draft-19.txt new file mode 100644 index 0000000..289a047 --- /dev/null +++ b/docs/specs/references/token-status-list-draft-19.txt @@ -0,0 +1,4368 @@ + + + + +Web Authorization Protocol T. Looker +Internet-Draft MATTR +Intended status: Standards Track P. Bastian +Expires: 21 September 2026 Bundesdruckerei + C. Bormann + SPRIND + 20 March 2026 + + + Token Status List (TSL) + draft-ietf-oauth-status-list-19 + +Abstract + + This specification defines a status mechanism called Token Status + List (TSL), data structures and processing rules for representing the + status of tokens secured by JSON Object Signing and Encryption (JOSE) + or CBOR Object Signing and Encryption (COSE), such as JWT, SD-JWT, + CBOR Web Token, and ISO mdoc. It also defines an extension point and + a registry for future status mechanisms. + +About This Document + + This note is to be removed before publishing as an RFC. + + The latest revision of this draft can be found at https://oauth- + wg.github.io/draft-ietf-oauth-status-list/draft-ietf-oauth-status- + list.html. Status information for this document may be found at + https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/. + + Discussion of this document takes place on the Web Authorization + Protocol Working Group mailing list (mailto:oauth@ietf.org), which is + archived at https://mailarchive.ietf.org/arch/browse/oauth/. + Subscribe at https://www.ietf.org/mailman/listinfo/oauth/. + + Source for this draft and an issue tracker can be found at + https://github.com/oauth-wg/draft-ietf-oauth-status-list. + +Status of This Memo + + This Internet-Draft is submitted in full conformance with the + provisions of BCP 78 and BCP 79. + + Internet-Drafts are working documents of the Internet Engineering + Task Force (IETF). Note that other groups may also distribute + working documents as Internet-Drafts. The list of current Internet- + Drafts is at https://datatracker.ietf.org/drafts/current/. + + + + +Looker, et al. Expires 21 September 2026 [Page 1] + +Internet-Draft Token Status List (TSL) March 2026 + + + Internet-Drafts are draft documents valid for a maximum of six months + and may be updated, replaced, or obsoleted by other documents at any + time. It is inappropriate to use Internet-Drafts as reference + material or to cite them other than as "work in progress." + + This Internet-Draft will expire on 21 September 2026. + +Copyright Notice + + Copyright (c) 2026 IETF Trust and the persons identified as the + document authors. All rights reserved. + + This document is subject to BCP 78 and the IETF Trust's Legal + Provisions Relating to IETF Documents (https://trustee.ietf.org/ + license-info) in effect on the date of publication of this document. + Please review these documents carefully, as they describe your rights + and restrictions with respect to this document. Code Components + extracted from this document must include Revised BSD License text as + described in Section 4.e of the Trust Legal Provisions and are + provided without warranty as described in the Revised BSD License. + +Table of Contents + + 1. Introduction . . . . . . . . . . . . . . . . . . . . . . . . 4 + 1.1. Example Use Cases . . . . . . . . . . . . . . . . . . . . 6 + 1.2. Rationale . . . . . . . . . . . . . . . . . . . . . . . . 7 + 1.3. Design Considerations . . . . . . . . . . . . . . . . . . 7 + 1.4. Prior Work . . . . . . . . . . . . . . . . . . . . . . . 8 + 1.5. Status Mechanisms Registry . . . . . . . . . . . . . . . 8 + 2. Conventions and Definitions . . . . . . . . . . . . . . . . . 8 + 3. Terminology . . . . . . . . . . . . . . . . . . . . . . . . . 8 + 4. Status List . . . . . . . . . . . . . . . . . . . . . . . . . 9 + 4.1. Compressed Byte Array . . . . . . . . . . . . . . . . . . 10 + 4.2. Status List in JSON Format . . . . . . . . . . . . . . . 12 + 4.3. Status List in CBOR Format . . . . . . . . . . . . . . . 13 + 5. Status List Token . . . . . . . . . . . . . . . . . . . . . . 14 + 5.1. Status List Token in JWT Format . . . . . . . . . . . . . 14 + 5.2. Status List Token in CWT Format . . . . . . . . . . . . . 16 + 6. Referenced Token . . . . . . . . . . . . . . . . . . . . . . 18 + 6.1. Status Claim . . . . . . . . . . . . . . . . . . . . . . 18 + 6.2. Referenced Token in JOSE . . . . . . . . . . . . . . . . 18 + 6.3. Referenced Token in COSE . . . . . . . . . . . . . . . . 20 + 7. Status Types . . . . . . . . . . . . . . . . . . . . . . . . 22 + 7.1. Status Types Values . . . . . . . . . . . . . . . . . . . 22 + 8. Verification and Processing . . . . . . . . . . . . . . . . . 23 + 8.1. Status List Request . . . . . . . . . . . . . . . . . . . 23 + 8.2. Status List Response . . . . . . . . . . . . . . . . . . 24 + 8.3. Validation Rules . . . . . . . . . . . . . . . . . . . . 25 + + + +Looker, et al. Expires 21 September 2026 [Page 2] + +Internet-Draft Token Status List (TSL) March 2026 + + + 8.4. Historical Resolution . . . . . . . . . . . . . . . . . . 27 + 9. Status List Aggregation . . . . . . . . . . . . . . . . . . . 28 + 9.1. Issuer Metadata . . . . . . . . . . . . . . . . . . . . . 29 + 9.2. Status List Parameter . . . . . . . . . . . . . . . . . . 29 + 9.3. Status List Aggregation Data Structure . . . . . . . . . 29 + 10. X.509 Certificate Extended Key Usage Extension . . . . . . . 30 + 11. Security Considerations . . . . . . . . . . . . . . . . . . . 31 + 11.1. Correct decoding and parsing of the encoded Status + List . . . . . . . . . . . . . . . . . . . . . . . . . . 31 + 11.2. Security Guidance for JWT and CWT . . . . . . . . . . . 31 + 11.3. Key Resolution and Trust Management . . . . . . . . . . 31 + 11.4. Redirection 3xx . . . . . . . . . . . . . . . . . . . . 33 + 11.5. Expiration and Caching . . . . . . . . . . . . . . . . . 33 + 11.6. Status List Token Protection . . . . . . . . . . . . . . 34 + 12. Privacy Considerations . . . . . . . . . . . . . . . . . . . 34 + 12.1. Observability of Issuers . . . . . . . . . . . . . . . . 34 + 12.2. Issuer Tracking of Referenced Tokens . . . . . . . . . . 35 + 12.3. Observability of Relying Parties . . . . . . . . . . . . 35 + 12.4. Observability of Outsiders . . . . . . . . . . . . . . . 36 + 12.5. Unlinkability . . . . . . . . . . . . . . . . . . . . . 36 + 12.5.1. Cross-party Collusion . . . . . . . . . . . . . . . 36 + 12.6. External Status Provider for Privacy . . . . . . . . . . 37 + 12.7. Historical Resolution . . . . . . . . . . . . . . . . . 37 + 12.8. Status Types . . . . . . . . . . . . . . . . . . . . . . 37 + 13. Operational Considerations . . . . . . . . . . . . . . . . . 38 + 13.1. Token Lifecycle . . . . . . . . . . . . . . . . . . . . 38 + 13.2. Linkability Mitigation . . . . . . . . . . . . . . . . . 38 + 13.3. Default Values and Double Allocation . . . . . . . . . . 38 + 13.4. Status List Size . . . . . . . . . . . . . . . . . . . . 39 + 13.5. External Status Issuer . . . . . . . . . . . . . . . . . 39 + 13.6. External Status Provider for Scalability . . . . . . . . 40 + 13.7. Status List Update Interval and Caching . . . . . . . . 40 + 13.8. Relying Parties avoiding correlatable Information . . . 41 + 13.9. Status List Formats . . . . . . . . . . . . . . . . . . 41 + 14. IANA Considerations . . . . . . . . . . . . . . . . . . . . . 42 + 14.1. JSON Web Token Claims Registration . . . . . . . . . . . 42 + 14.1.1. Registry Contents . . . . . . . . . . . . . . . . . 42 + 14.2. JWT Status Mechanisms Registry . . . . . . . . . . . . . 43 + 14.2.1. Registration Template . . . . . . . . . . . . . . . 43 + 14.2.2. Initial Registry Contents . . . . . . . . . . . . . 44 + 14.3. CBOR Web Token Claims Registration . . . . . . . . . . . 44 + 14.3.1. Registry Contents . . . . . . . . . . . . . . . . . 44 + 14.4. CWT Status Mechanisms Registry . . . . . . . . . . . . . 45 + 14.4.1. Registration Template . . . . . . . . . . . . . . . 46 + 14.4.2. Initial Registry Contents . . . . . . . . . . . . . 46 + 14.5. OAuth Status Types Registry . . . . . . . . . . . . . . 47 + 14.5.1. Registration Template . . . . . . . . . . . . . . . 47 + 14.5.2. Initial Registry Contents . . . . . . . . . . . . . 48 + + + +Looker, et al. Expires 21 September 2026 [Page 3] + +Internet-Draft Token Status List (TSL) March 2026 + + + 14.6. OAuth Parameters Registration . . . . . . . . . . . . . 49 + 14.7. Media Type Registration . . . . . . . . . . . . . . . . 50 + 14.8. CoAP Content-Format Registrations . . . . . . . . . . . 51 + 14.9. X.509 Certificate Extended Key Purpose OID + Registration . . . . . . . . . . . . . . . . . . . . . . 52 + 15. Acknowledgments . . . . . . . . . . . . . . . . . . . . . . . 52 + 16. References . . . . . . . . . . . . . . . . . . . . . . . . . 52 + 16.1. Normative References . . . . . . . . . . . . . . . . . . 52 + 16.2. Informative References . . . . . . . . . . . . . . . . . 54 + Appendix A. ASN.1 Module . . . . . . . . . . . . . . . . . . . . 56 + Appendix B. Size Comparison . . . . . . . . . . . . . . . . . . 57 + Size of Status Lists for varying amount of entries and revocation + rates . . . . . . . . . . . . . . . . . . . . . . . . . . 57 + Size of compressed array of UUIDv4 (128-bit UUIDs) for varying + amount of entries and revocation rates . . . . . . . . . 58 + Appendix C. Test vectors for Status List encoding . . . . . . . 58 + C.1. 1-bit Status List . . . . . . . . . . . . . . . . . . . . 59 + C.2. 2-bit Status List . . . . . . . . . . . . . . . . . . . . 59 + C.3. 4-bit Status List . . . . . . . . . . . . . . . . . . . . 60 + C.4. 8-bit Status List . . . . . . . . . . . . . . . . . . . . 62 + Document History . . . . . . . . . . . . . . . . . . . . . . . . 70 + Authors' Addresses . . . . . . . . . . . . . . . . . . . . . . . 78 + +1. Introduction + + Token formats secured by JOSE [RFC7515] or COSE [RFC9052], such as + JWTs [RFC7519], SD-JWTs [RFC9901], SD-JWT VCs [SD-JWT.VC], CWTs + [RFC8392], SD-CWTs [SD-CWT] and ISO mdoc [ISO.mdoc], have vast + possible applications. Some of these applications can involve + issuing a token whereby certain semantics about the token or its + validity may change over time. Communicating these changes to + relying parties in an interoperable manner, such as whether the token + is considered invalidated or suspended by its issuer is important for + many of these applications. + + This document defines a Status List data structure that describes the + individual statuses of multiple Referenced Tokens. A Referenced + Token may be of any format, but is most commonly a data structure + secured by JOSE or COSE. The Referenced Token is referenced by the + Status List, which describes the status of the Referenced Token. The + statuses of all Referenced Tokens are conveyed via a bit array in the + Status List. Each Referenced Token is allocated an index during + issuance that represents its position within this bit array. The + value of the bit(s) at this index corresponds to the Referenced + Token's status. A Status List is provided within a Status List Token + protected by cryptographic signature or MAC and this document defines + its representations in JWT and CWT format. + + + + +Looker, et al. Expires 21 September 2026 [Page 4] + +Internet-Draft Token Status List (TSL) March 2026 + + + The following diagram depicts the relationship between the artifacts: + + +----------------+ describes status +------------------+ + | Status List |------------------->| Referenced Token | + | (JSON or CBOR) |<-------------------| (JOSE, COSE, ..) | + +-------+--------+ references +------------------+ + | + | + | embedded in + v + +-------------------+ + | Status List Token | + | (JWT or CWT) | + +-------------------+ + + An Issuer issues Referenced Tokens to a Holder, the Holder uses and + presents those Referenced Tokens to a Relying Party. The Issuer + gives updated status information to the Status Issuer, who issues a + Status List Token. The Status Issuer can be either the Issuer or an + entity that has been authorized by the Issuer to issue Status List + Tokens. The Status Issuer provides the Status List Token to the + Status Provider, who serves the Status List Token on an accessible + endpoint. The Relying Party or the Holder may fetch the Status List + Token to retrieve the status of the Referenced Token. + + The roles of the Issuer (of the Referenced Token), the Status Issuer + and the Status Provider may be fulfilled by the same entity. If not + further specified, the term Issuer may refer to an entity acting for + all three roles. This document describes how an Issuer references a + Status List Token and how a Relying Party fetches and validates + Status Lists. + + The following diagram depicts the relationship between the involved + roles (Relying Party is equivalent to Verifier of [RFC9901]): + + + + + + + + + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 5] + +Internet-Draft Token Status List (TSL) March 2026 + + + issue present + Referenced Referenced + +--------+ Token +--------+ Token +---------------+ + | Issuer |----------->| Holder |----------->| Relying Party | + +---+----+ +---+----+ +-------+-------+ + | | | + v provide status | | + +---------------+ | | + | Status Issuer | | | + +---+-----------+ | | + | | | + v provide Status List | | + +-----------------+ | | + | Status Provider |<------+-------------------------+ + +-----------------+ fetch Status List Token + + Status Lists can be used to express a variety of Status Types. This + document defines basic Status Types for the most common use cases as + well as an extensibility mechanism for custom Status Types. + + Furthermore, the document creates an extension point and an IANA + registry that enables other specifications to describe additional + status mechanisms. + +1.1. Example Use Cases + + An example of the usage of a Status List is to manage the statuses of + issued access tokens as defined in Section 1.4 of [RFC6749]. Token + Introspection [RFC7662] provides a method to determine the status of + an issued access token, but it necessitates the party attempting to + validate the state of access tokens to directly contact the Issuer of + each token for validation. In contrast, the mechanism defined in + this specification allows a party to retrieve the statuses for many + tokens, reducing interactions with the Issuer substantially. This + not only improves scalability but also enhances privacy by preventing + the Issuer from gaining knowledge of access tokens being verified + (herd anonymity). + + Another possible use case for the Status List is to express the + status of verifiable credentials (Referenced Tokens) issued by an + Issuer in the Issuer-Holder-Verifier model [RFC9901]. + + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 6] + +Internet-Draft Token Status List (TSL) March 2026 + + +1.2. Rationale + + Revocation mechanisms are an essential part of most identity + ecosystems. In the past, revocation of X.509 TLS certificates has + been proven difficult. Traditional certificate revocation lists + (CRLs) have limited scalability; Online Certificate Status Protocol + (OCSP) has additional privacy risks, since the client is leaking the + requested website to a third party. OCSP stapling addresses some of + these problems at the cost of less up-to-date data. Approaches based + on cryptographic accumulators and Zero-Knowledge-Proofs try to + accommodate for this privacy gap, but are currently (in 2026) facing + scalability issues and are not yet standardized. Another alternative + is short-lived Referenced Tokens with regular re-issuance, but this + puts additional burden on the Issuer's infrastructure. + + This specification seeks to find a balance between scalability, + security and privacy by representing statuses as individual bits, + packing them into an array, and compressing the resulting binary + data. Thereby, a Status List may contain statuses of many thousands + or millions Referenced Tokens while remaining as small as possible. + Placing a large number of Referenced Tokens into the same list also + offers Holders and Relying Parties herd privacy from the Status + Provider. + +1.3. Design Considerations + + The decisions taken in this specification aim to achieve the + following design goals: + + * the specification shall be easy, fast and secure to implement in + all major programming languages + + * the specification shall be optimized to support the most common + use cases, such as revocation, and avoid unnecessary complexity of + corner cases, such as providing multiple statuses for a single + token + + * the Status List shall scale up to millions of tokens to support + large-scale government or enterprise use cases + + * the Status List shall enable caching policies and offline support + + * the specification shall support JSON and CBOR based tokens + + * the specification shall not specify key resolution or trust + frameworks + + + + + +Looker, et al. Expires 21 September 2026 [Page 7] + +Internet-Draft Token Status List (TSL) March 2026 + + + * the specification shall define an extension point that enables + other mechanisms to convey information about the status of a + Referenced Token + +1.4. Prior Work + + Representing statuses with bits in an array is a rather old and well- + known concept in computer science. There has been prior work to use + this for revocation and status management. For example, a paper by + Smith et al. [smith2020let] proposed a mechanism called Certificate + Revocation Vectors based on xz compressed bit vectors for each + expiration day. The W3C bit Status List [W3C.SL] similarly uses a + compressed bit representation. + +1.5. Status Mechanisms Registry + + This specification establishes IANA "Status Mechanisms" registries + for status mechanisms for JOSE-based tokens and for status mechanisms + for COSE-based tokens and registers the members defined by this + specification. Other specifications can register other members used + for status retrieval. + + Other status mechanisms may have different tradeoffs regarding + security, privacy, scalability and complexity. The privacy and + security considerations in this document only represent the + properties of the Status List mechanism. + +2. Conventions and Definitions + + The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", + "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and + "OPTIONAL" in this document are to be interpreted as described in + BCP 14 [RFC2119] [RFC8174] when, and only when, they appear in all + capitals, as shown here. + +3. Terminology + + Issuer: An entity that issues the Referenced Token. Also known as a + Provider. + + Status Issuer: An entity that issues the Status List Token about the + status information of the Referenced Token. This role may be + fulfilled by the Issuer. + + Status Provider: An entity that provides the Status List Token on an + accessible endpoint. This role may be fulfilled by the Status + Issuer. + + + + +Looker, et al. Expires 21 September 2026 [Page 8] + +Internet-Draft Token Status List (TSL) March 2026 + + + Holder: An entity that receives Referenced Tokens from the Issuer + and presents them to Relying Parties. + + Relying Party: An entity that relies on the Referenced Token and + fetches the corresponding Status List Token to validate the status + of that Referenced Token. Also known as a Verifier. + + Status: A Status describes the current state, mode, condition or + stage of an entity that is represented by the Referenced Token as + determined by the Status Issuer. + + Status List: An object in JSON or CBOR representation containing a + compressed byte array that represents the statuses of many + Referenced Tokens. + + Status List Token: A token in JWT (as defined in [RFC7519]) or CWT + (as defined in [RFC8392]) representation that contains a + cryptographically secured Status List. + + Referenced Token: A cryptographically secured data structure that + contains a "status" claim that references a mechanism to retrieve + status information about this Referenced Token. This document + defines the Status List mechanism in which case the Referenced + Token contains a reference to an entry in a Status List Token. It + is RECOMMENDED to use JSON [RFC8259] with JOSE as defined in + [RFC7515] or CBOR [RFC8949] with COSE as defined in [RFC9052]. + Examples for Referenced Tokens are SD-JWT and ISO mdoc. + + Client: An application that fetches information, such as a Status + List Token, from the Status List Provider on behalf of the Holder + or Relying Party. + + base64url: Denotes the URL-safe base64 encoding with all trailing + '=' characters omitted as defined in Section 2 of [RFC7515] as + "Base64url Encoding". + +4. Status List + + A Status List is a data structure that contains the statuses of many + Referenced Tokens represented by one or multiple bits. Section 4.1 + describes how to construct a compressed byte array that is the base + component for the Status List data structure. Section 4.2 and + Section 4.3 describe how to encode such a Status List in JSON and + CBOR representations. + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 9] + +Internet-Draft Token Status List (TSL) March 2026 + + +4.1. Compressed Byte Array + + A compressed byte array containing the status information of the + Referenced Token is composed by the following algorithm: + + 1. The Status Issuer MUST define a number of bits (bits) of either + 1,2,4 or 8, that represents the number of bits used to describe + the status of each Referenced Token within this Status List. + Therefore, up to 2,4,16 or 256 statuses for a Referenced Token + are possible, depending on the bit size. This limitation is + intended to limit bit manipulation necessary to a single byte for + every operation, thus keeping implementations simpler and less + error-prone. + + 2. The Status Issuer creates a byte array of size = number of + Referenced Tokens * bits / 8 or greater. Depending on the bits, + each byte in the array corresponds to 8/(bits) statuses (8,4,2 or + 1). + + 3. The Status Issuer sets the status values for all Referenced + Tokens within the byte array. Each Referenced Token is assigned + a distinct index from 0 to one less than the number of Referenced + Tokens assigned to the Status List. Each index identifies a + contiguous block of bits in the byte array, with the blocks being + packed into bytes from the least significant bit ("0") to the + most significant bit ("7"). These bits contain the encoded + status value of the Referenced Token (see Section 7 for more + details on the values). + + 4. The Status Issuer compresses the byte array using DEFLATE + [RFC1951] with the ZLIB [RFC1950] data format. Implementations + are RECOMMENDED to use the highest compression level available. + + The following example illustrates the byte array of a Status List + that represents the statuses of 16 Referenced Tokens with a bits of + 1, requiring 2 bytes (16 bits) for the uncompressed byte array: + + + + + + + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 10] + +Internet-Draft Token Status List (TSL) March 2026 + + + status[0] = 0b1 + status[1] = 0b0 + status[2] = 0b0 + status[3] = 0b1 + status[4] = 0b1 + status[5] = 0b1 + status[6] = 0b0 + status[7] = 0b1 + status[8] = 0b1 + status[9] = 0b1 + status[10] = 0b0 + status[11] = 0b0 + status[12] = 0b0 + status[13] = 0b1 + status[14] = 0b0 + status[15] = 0b1 + + These bits are concatenated: + + Byte Index 0 1 + Bit Position 7 6 5 4 3 2 1 0 7 6 5 4 3 2 1 0 + +-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+ + Bit Values |1|0|1|1|1|0|0|1| |1|0|1|0|0|0|1|1| + +-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+ + List Index 7 6 5 4 3 2 1 0 15 ... 10 9 8 + \_______________/ \_______________/ + Hex Value 0xB9 0xA3 + + compressed array (hex): 78dadbb918000217015d + + In the following example, the Status List additionally includes the + Status Type "SUSPENDED". As the Status Type value for "SUSPENDED" is + 0x02 and does not fit into 1 bit, the bits is required to be 2. This + example illustrates the byte array of a Status List that represents + the statuses of 12 Referenced Tokens with a bits of 2, requiring 3 + bytes (24 bits) for the uncompressed byte array: + + + + + + + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 11] + +Internet-Draft Token Status List (TSL) March 2026 + + + status[0] = 0b01 + status[1] = 0b10 + status[2] = 0b00 + status[3] = 0b11 + status[4] = 0b00 + status[5] = 0b01 + status[6] = 0b00 + status[7] = 0b01 + status[8] = 0b01 + status[9] = 0b10 + status[10] = 0b11 + status[11] = 0b11 + + These bits are concatenated: + + Byte Index 0 1 2 + Bit Position 7 6 5 4 3 2 1 0 7 6 5 4 3 2 1 0 7 6 5 4 3 2 1 0 + +-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+ + Bit Values |1|1|0|0|1|0|0|1| |0|1|0|0|0|1|0|0| |1|1|1|1|1|0|0|1| + +-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+ +-+-+-+-+-+-+-+-+ + \ / \ / \ / \ / \ / \ / \ / \ / \ / \ / \ / \ / + Status Value 11 00 10 01 01 00 01 00 11 11 10 01 + List Index 3 2 1 0 7 6 5 4 11 10 9 8 + \___________/ \___________/ \___________/ + Hex Value 0xC9 0x44 0xF9 + + compressed array (hex): 78da3be9f2130003df0207 + +4.2. Status List in JSON Format + + This section defines the data structure for a JSON-encoded Status + List: + + * The StatusList structure is a JSON Object that contains the + following members: + + - bits: REQUIRED. JSON Integer specifying the number of bits per + Referenced Token in the compressed byte array (lst). The + allowed values for bits are 1, 2, 4, and 8. + + - lst: REQUIRED. JSON String that contains the status values for + all the Referenced Tokens it conveys statuses for. The value + MUST be the base64url-encoded compressed byte array as + specified in Section 4.1. + + - aggregation_uri: OPTIONAL. JSON String that contains a URI to + retrieve the Status List Aggregation for this type of + Referenced Token or Issuer. See Section 9 for further details. + + + +Looker, et al. Expires 21 September 2026 [Page 12] + +Internet-Draft Token Status List (TSL) March 2026 + + + The following example illustrates the JSON representation of the + Status List with bits=1 from the examples above: + + byte_array = [0xb9, 0xa3] + encoded: + { + "bits": 1, + "lst": "eNrbuRgAAhcBXQ" + } + + The following example illustrates the JSON representation of the + Status List with bits=2 from the examples above: + + byte_array = [0xc9, 0x44, 0xf9] + encoded: + { + "bits": 2, + "lst": "eNo76fITAAPfAgc" + } + + See Appendix C for more test vectors. + +4.3. Status List in CBOR Format + + This section defines the data structure for a CBOR-encoded Status + List: + + * The StatusList structure is a CBOR map (major type 5) and defines + the following entries: + + - bits: REQUIRED. CBOR Unsigned integer (major type 0) that + contains the number of bits per Referenced Token in the + compressed byte array (lst). The allowed values for bits are + 1, 2, 4, and 8. + + - lst: REQUIRED. CBOR Byte string (major type 2) that contains + the status values for all the Referenced Tokens it conveys + statuses for. The value MUST be the compressed byte array as + specified in Section 4.1. + + - aggregation_uri: OPTIONAL. CBOR Text string (major type 3) + that contains a URI to retrieve the Status List Aggregation for + this type of Referenced Token. See Section 9 for further + detail. + + The following is the CDDL [RFC8610] definition of the StatusList + structure: + + + + +Looker, et al. Expires 21 September 2026 [Page 13] + +Internet-Draft Token Status List (TSL) March 2026 + + + StatusList = { + bits: 1 / 2 / 4 / 8, ; The number of bits used per Referenced Token + lst: bstr, ; Byte string that contains the Status List + ? aggregation_uri: tstr ; link to the Status List Aggregation + } + + The following example illustrates the CBOR representation of the + Status List in Hex: + + byte_array = [0xb9, 0xa3] + encoded: + a2646269747301636c73744a78dadbb918000217015d + + The following is the CBOR Annotated Hex output of the example above: + + a2 # map(2) + 64 # string(4) + 62697473 # "bits" + 01 # uint(1) + 63 # string(3) + 6c7374 # "lst" + 4a # bytes(10) + 78dadbb918000217015d # "xÚÛ¹\x18\x00\x02\x17\x01]" + + See Appendix C for more test vectors. + +5. Status List Token + + A Status List Token embeds a Status List into a token that is + cryptographically signed and protects the integrity of the Status + List. This allows for the Status List Token to be hosted by third + parties or be transferred for offline use cases. + + This section specifies Status List Tokens in JSON Web Token (JWT) and + CBOR Web Token (CWT) format. + +5.1. Status List Token in JWT Format + + The Status List Token MUST be encoded as a "JSON Web Token (JWT)" + according to [RFC7519]. + + The following content applies to the JWT Header: + + * typ: REQUIRED. The JWT type MUST be statuslist+jwt. + + The following content applies to the JWT Claims Set: + + + + + +Looker, et al. Expires 21 September 2026 [Page 14] + +Internet-Draft Token Status List (TSL) March 2026 + + + * sub: REQUIRED. As generally defined in [RFC7519]. The sub + (subject) claim MUST specify the URI of the Status List Token. + The value MUST be equal to that of the uri claim contained in the + status_list claim of the Referenced Token. + + * iat: REQUIRED. As generally defined in [RFC7519]. The iat + (issued at) claim MUST specify the time at which the Status List + Token was issued. + + * exp: RECOMMENDED. As generally defined in [RFC7519]. The exp + (expiration time) claim, if present, MUST specify the time at + which the Status List Token is considered expired by the Status + Issuer. Consider the guidance provided in Section 13.7. + + * ttl: RECOMMENDED. The ttl (time to live) claim, if present, MUST + specify the maximum amount of time, in seconds, that the Status + List Token can be cached by a consumer before a fresh copy SHOULD + be retrieved. The value of the claim MUST be a positive number + encoded in JSON as a number. Consider the guidance provided in + Section 13.7. + + * status_list: REQUIRED. The status_list (status list) claim MUST + specify the Status List conforming to the structure defined in + Section 4.2. + + The following additional rules apply: + + 1. The JWT MAY contain other claims. + + 2. The JWT MUST be secured using a cryptographic signature or MAC + algorithm. Relying Parties MUST reject JWTs with an invalid + signature. + + 3. Relying Parties MUST reject JWTs that are not valid in all other + respects per "JSON Web Token (JWT)" [RFC7519]. + + 4. Application of additional restrictions and policies are at the + discretion of the Relying Party. + + The following is a non-normative example of a Status List Token in + JWT format: + + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 15] + +Internet-Draft Token Status List (TSL) March 2026 + + + { + "alg": "ES256", + "kid": "12", + "typ": "statuslist+jwt" + } + . + { + "exp": 2291720170, + "iat": 1686920170, + "status_list": { + "bits": 1, + "lst": "eNrbuRgAAhcBXQ" + }, + "sub": "https://example.com/statuslists/1", + "ttl": 43200 + } + +5.2. Status List Token in CWT Format + + The Status List Token MUST be encoded as a "CBOR Web Token (CWT)" + according to [RFC8392]. The Status List Token MUST NOT be tagged + with the CWT tag defined in Section 6 of [RFC8392]. The COSE message + MUST either be the tagged COSE_Sign1_Tagged (18) or COSE_Mac0_Tagged + (17) as defined in Section 2 of [RFC9052]. + + The following content applies to the protected header of the CWT: + + * 16 (type): REQUIRED. The type of the CWT MUST be application/ + statuslist+cwt or the registered CoAP Content-Format ID (see + Section 14.8) as defined in [RFC9596]. + + The following content applies to the CWT Claims Set: + + * 2 (subject): REQUIRED. As generally defined in [RFC8392]. The + subject claim MUST specify the URI of the Status List Token. The + value MUST be equal to that of the uri claim contained in the + status_list claim of the Referenced Token. + + * 6 (issued at): REQUIRED. As generally defined in [RFC8392]. The + issued at claim MUST specify the time at which the Status List + Token was issued. + + * 4 (expiration time): RECOMMENDED. As generally defined in + [RFC8392]. The expiration time claim, if present, MUST specify + the time at which the Status List Token is considered expired by + its issuer. Consider the guidance provided in Section 13.7. + + + + + +Looker, et al. Expires 21 September 2026 [Page 16] + +Internet-Draft Token Status List (TSL) March 2026 + + + * 65534 (time to live): RECOMMENDED. Unsigned integer (major type + 0). The time to live claim, if present, MUST specify the maximum + amount of time, in seconds, that the Status List Token can be + cached by a consumer before a fresh copy SHOULD be retrieved. The + value of the claim MUST be a positive number. Consider the + guidance provided in Section 13.7. + + * 65533 (status list): REQUIRED. The status list claim MUST specify + the Status List conforming to the structure defined in + Section 4.3. + + The following additional rules apply: + + 1. The CWT MAY contain other claims. + + 2. The CWT MUST be secured using a cryptographic signature or MAC + algorithm. Relying Parties MUST reject CWTs with an invalid + signature. + + 3. Relying Parties MUST reject CWTs that are not valid in all other + respects per "CBOR Web Token (CWT)" [RFC8392]. + + 4. Application of additional restrictions and policies are at the + discretion of the Relying Party. + + The following is a non-normative example of a Status List Token in + CWT format in Hex: + + d2845820a2012610781a6170706c69636174696f6e2f7374617475736c6973742b63 + 7774a1044231325850a502782168747470733a2f2f6578616d706c652e636f6d2f73 + 74617475736c697374732f31061a648c5bea041a8898dfea19fffe19a8c019fffda2 + 646269747301636c73744a78dadbb918000217015d584093fa4d01032b18c35e2fe1 + 101b77fd6cc9440022caa4694450c4e4e9feab4e99d1fa6d9772ce2bf3a12e0323de + d7c982c5e101a5e67f0cbc1e2b6f57ce99c279 + + The following is the CBOR Annotated Hex output of the example above: + + + + + + + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 17] + +Internet-Draft Token Status List (TSL) March 2026 + + +d2 # tag(18) + 84 # array(4) + 58 20 # bytes(32) + a2012610781a6170706c6963 # "¢\x01&\x10x\x1aapplic" + 6174696f6e2f737461747573 # "ation/status" + 6c6973742b637774 # "list+cwt" + a1 # map(1) + 04 # uint(4) + 42 # bytes(2) + 3132 # "12" + 58 50 # bytes(80) + a502782168747470733a2f2f # "¥\x02x!https://" + 6578616d706c652e636f6d2f # "example.com/" + 7374617475736c697374732f # "statuslists/" + 31061a648c5bea041a8898df # "1\x06\x1ad\x8c[ê\x04\x1a\x88\x98ß" + ea19fffe19a8c019fffda264 # "ê\x19ÿþ\x19¨À\x19ÿý¢d" + 6269747301636c73744a78da # "bits\x01clstJxÚ" + dbb918000217015d # "Û¹\x18\x00\x02\x17\x01]" + 58 40 # bytes(64) + 93fa4d01032b18c35e2fe110 # "\x93úM\x01\x03+\x18Ã^/á\x10" + 1b77fd6cc9440022caa46944 # "\x1bwýlÉD\x00"ʤiD" + 50c4e4e9feab4e99d1fa6d97 # "PÄäéþ«N\x99Ñúm\x97" + 72ce2bf3a12e0323ded7c982 # "rÎ+ó¡.\x03#Þ×É\x82" + c5e101a5e67f0cbc1e2b6f57 # "Åá\x01¥æ\x7f\x0c¼\x1e+oW" + ce99c279 # "Î\x99Ây" + +6. Referenced Token + +6.1. Status Claim + + By including a "status" claim in a Referenced Token, the Issuer is + referencing a mechanism to retrieve status information about this + Referenced Token. This specification defines one possible member of + the "status" object, called "status_list". Other members of the + "status" object may be defined by other specifications. This is + analogous to "cnf" claim in Section 3.1 of [RFC7800] in which + different authenticity confirmation methods can be included. + +6.2. Referenced Token in JOSE + + The Referenced Token MAY be encoded as a "JSON Web Token (JWT)" + according to [RFC7519], as an SD-JWT [RFC9901], as an SD-JWT VC + [SD-JWT.VC] or other formats based on JOSE. + + The following content applies to the JWT Claims Set: + + * status: REQUIRED. The status (status) claim MUST specify a JSON + Object that contains at least one reference to a status mechanism. + + + +Looker, et al. Expires 21 September 2026 [Page 18] + +Internet-Draft Token Status List (TSL) March 2026 + + + - status_list: REQUIRED when the status mechanism defined in this + specification is used. It MUST specify a JSON Object that + contains a reference to a Status List Token. It MUST at least + contain the following claims: + + o idx: REQUIRED. The idx (index) claim MUST specify a non- + negative Integer that represents the index to check for + status information in the Status List for the current + Referenced Token. + + o uri: REQUIRED. The uri (URI) claim MUST specify a String + value that identifies the Status List Token containing the + status information for the Referenced Token. The value of + uri MUST be a URI conforming to [RFC3986]. + + Application of additional restrictions and policies are at the + discretion of the Relying Party. + + The following is a non-normative example of a decoded header and + payload of a Referenced Token: + + { + "alg": "ES256", + "kid": "11" + } + . + { + "status": { + "status_list": { + "idx": 0, + "uri": "https://example.com/statuslists/1" + } + } + } + + The following is a non-normative example of a Referenced Token in SD- + JWT serialized form as received from an Issuer: + + + + + + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 19] + +Internet-Draft Token Status List (TSL) March 2026 + + + eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0In0.eyJfc2QiOiBb + Ikh2cktYNmZQVjB2OUtfeUNWRkJpTEZIc01heGNEXzExNEVtNlZUOHgxbGciXSwgImlz + cyI6ICJodHRwczovL2V4YW1wbGUuY29tL2lzc3VlciIsICJpYXQiOiAxNjgzMDAwMDAw + LCAiZXhwIjogMTg4MzAwMDAwMCwgInN1YiI6ICI2YzVjMGE0OS1iNTg5LTQzMWQtYmFl + Ny0yMTkxMjJhOWVjMmMiLCAic3RhdHVzIjogeyJzdGF0dXNfbGlzdCI6IHsiaWR4Ijog + MCwgInVyaSI6ICJodHRwczovL2V4YW1wbGUuY29tL3N0YXR1c2xpc3RzLzEifX0sICJf + c2RfYWxnIjogInNoYS0yNTYifQ.-kgS-R-Z4DEDlqb8kb6381_gHHNatsoF1fcVKZk3M + 06CrnV8F8k9d2w2V_YAOvgcb0f11FqDFezXBXH30d4vcw~WyIyR0xDNDJzS1F2ZUNmR2 + ZyeU5STjl3IiwgInN0cmVldF9hZGRyZXNzIiwgIlNjaHVsc3RyLiAxMiJd~WyJlbHVWN + U9nM2dTTklJOEVZbnN4QV9BIiwgImxvY2FsaXR5IiwgIlNjaHVscGZvcnRhIl0~WyI2S + Wo3dE0tYTVpVlBHYm9TNXRtdlZBIiwgInJlZ2lvbiIsICJTYWNoc2VuLUFuaGFsdCJd~ + WyJlSThaV205UW5LUHBOUGVOZW5IZGhRIiwgImNvdW50cnkiLCAiREUiXQ~WyJRZ19PN + jR6cUF4ZTQxMmExMDhpcm9BIiwgImFkZHJlc3MiLCB7Il9zZCI6IFsiNnZoOWJxLXpTN + EdLTV83R3BnZ1ZiWXp6dTZvT0dYcm1OVkdQSFA3NVVkMCIsICI5Z2pWdVh0ZEZST0NnU + nJ0TmNHVVhtRjY1cmRlemlfNkVyX2o3NmttWXlNIiwgIktVUkRQaDRaQzE5LTN0aXotR + GYzOVY4ZWlkeTFvVjNhM0gxRGEyTjBnODgiLCAiV045cjlkQ0JKOEhUQ3NTMmpLQVN4V + GpFeVc1bTV4NjVfWl8ycm8yamZYTSJdfV0~ + + The resulting payload of the example above: + + { + "_sd": [ + "HvrKX6fPV0v9K_yCVFBiLFHsMaxcD_114Em6VT8x1lg" + ], + "iss": "https://example.com/issuer", + "iat": 1683000000, + "exp": 1883000000, + "sub": "6c5c0a49-b589-431d-bae7-219122a9ec2c", + "status": { + "status_list": { + "idx": 0, + "uri": "https://example.com/statuslists/1" + } + }, + "_sd_alg": "sha-256" + } + +6.3. Referenced Token in COSE + + The Referenced Token MAY be encoded as a "CBOR Web Token (CWT)" + object according to [RFC8392], as an SD-CWTs [SD-CWT] or as an ISO + mdoc according to [ISO.mdoc] or other formats based on COSE. + Referenced Tokens in CBOR SHOULD share the same core data structure + for a status list reference: + + * The Status CBOR structure is a Map that MUST include at least one + data item that refers to a status mechanism. Each data item in + the Status CBOR structure comprises a key-value pair, where the + + + +Looker, et al. Expires 21 September 2026 [Page 20] + +Internet-Draft Token Status List (TSL) March 2026 + + + key MUST be a CBOR text string (major type 3) specifying the + identifier of the status mechanism and the corresponding value + defines its contents. + + - status_list (status list): REQUIRED when the status mechanism + defined in this specification is used. It has the same + definition as the status_list claim in Section 6.2 but MUST be + encoded as a StatusListInfo CBOR structure with the following + fields: + + o idx: REQUIRED. Unsigned integer (major type 0). The idx + (index) claim MUST specify a non-negative Integer that + represents the index to check for status information in the + Status List for the current Referenced Token. + + o uri: REQUIRED. Text string (major type 3). The uri (URI) + claim MUST specify a String value that identifies the Status + List Token containing the status information for the + Referenced Token. The value of uri MUST be a URI conforming + to [RFC3986]. + + If the Referenced Token is a CWT, the following content applies to + the CWT Claims Set: + + * 65535 (status): REQUIRED. The status claim contains the Status + CBOR structure as described in this section. + + Application of additional restrictions and policies are at the + discretion of the Relying Party. + + The following is a non-normative example of a Referenced Token in CWT + format in Hex: + + d28443a10126a1044231325866a502653132333435017368747470733a2f2f657861 + 6d706c652e636f6d061a648c5bea041a8898dfea19ffffa16b7374617475735f6c69 + 7374a2636964780063757269782168747470733a2f2f6578616d706c652e636f6d2f + 7374617475736c697374732f315840340f7efea10f1a36dc4797636a17b4dd4848b6 + 8997d1d10e8cceb3a38ff33b3dda72964a83989f6cf98560c2fc97a08bc8977cc6b0 + f84cfedab93d3e4481e938 + + The following is the CBOR Annotated Hex output of the example above: + + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 21] + +Internet-Draft Token Status List (TSL) March 2026 + + +d2 # tag(18) + 84 # array(4) + 43 # bytes(3) + a10126 # "¡\x01&" + a1 # map(1) + 04 # uint(4) + 42 # bytes(2) + 3132 # "12" + 58 66 # bytes(102) + a50265313233343501736874 # "¥\x02e12345\x01sht" + 7470733a2f2f6578616d706c # "tps://exampl" + 652e636f6d061a648c5bea04 # "e.com\x06\x1ad\x8c[ê\x04" + 1a8898dfea19ffffa16b7374 # "\x1a\x88\x98ßê\x19ÿÿ¡kst" + 617475735f6c697374a26369 # "atus_list¢ci" + 647800637572697821687474 # "dx\x00curix!htt" + 70733a2f2f6578616d706c65 # "ps://example" + 2e636f6d2f7374617475736c # ".com/statusl" + 697374732f31 # "ists/1" + 58 40 # bytes(64) + 340f7efea10f1a36dc479763 # "4\x0f~þ¡\x0f\x1a6ÜG\x97c" + 6a17b4dd4848b68997d1d10e # "j\x17´ÝHH¶\x89\x97ÑÑ\x0e" + 8cceb3a38ff33b3dda72964a # "\x8cγ£\x8fó;=Úr\x96J" + 83989f6cf98560c2fc97a08b # "\x83\x98\x9flù\x85`Âü\x97\xa0\x8b" + c8977cc6b0f84cfedab93d3e # "È\x97|ưøLþÚ¹=>" + 4481e938 # "D\x81é8" + +7. Status Types + + This document defines the statuses of Referenced Tokens as Status + Type values. A Status List represents exactly one status per + Referenced Token. If the Status List contains more than one bit per + token (as defined by bits in the Status List), then the whole value + of bits MUST describe one value. Status Types MUST have a numeric + value between 0 and 255 for their representation in the Status List. + The issuer of the Status List MUST choose an adequate bits value (bit + size) to be able to describe the required Status Types for its + application. + +7.1. Status Types Values + + The processing rules for Referenced Tokens (such as JWT or CWT) + supersede the Referenced Token's status in a TSL. In particular, a + Referenced Token that is evaluated as being expired (e.g. through the + exp claim) but in a TSL has a status of 0x00 ("VALID"), is considered + expired. + + + + + + +Looker, et al. Expires 21 September 2026 [Page 22] + +Internet-Draft Token Status List (TSL) March 2026 + + + This document creates a registry in Section 14.5 that includes the + most common Status Type values. To improve interoperability, + applications MUST use registered values for statuses if they have the + same or compatiable semantics of the use-case. Additional values may + be defined for particular use cases. Status Types described by this + document comprise: + + * 0x00 - "VALID" - The status of the Referenced Token is valid, + correct or legal. + + * 0x01 - "INVALID" - The status of the Referenced Token is revoked, + annulled, taken back, recalled or cancelled. + + * 0x02 - "SUSPENDED" - The status of the Referenced Token is + temporarily invalid, hanging, debarred from privilege. This + status is usually temporary. + + The Status Type value 0x03 and Status Type values in the range 0x0C + until 0x0F are permanently reserved as application specific. The + processing of Status Types using these values is application + specific. All other Status Type values are reserved for future + registration. + + See Section 12.8 for privacy considerations on status types. + +8. Verification and Processing + + The fetching, processing and verifying of a Status List Token may be + done by either the Holder or the Relying Party. The following + section is described from the role of the Relying Party, however the + same rules apply to the Holder. + +8.1. Status List Request + + The default Status List request and response mechanism uses HTTP + semantics and Content negotiation as defined in [RFC9110]. + + The Status Provider MUST return the Status List Token in response to + an HTTP GET request to the URI provided in the Referenced Token, + unless the Relying Party and the Status Provider have alternative + methods of distribution for the Status List Token. + + The HTTP endpoint SHOULD support the use of Cross-Origin Resource + Sharing (CORS) [CORS] and/or other methods as appropriate to enable + Browser-based clients to access it, unless ecosystems using this + specification choose not to support Browser-based clients. + + + + + +Looker, et al. Expires 21 September 2026 [Page 23] + +Internet-Draft Token Status List (TSL) March 2026 + + + The following media types are defined by this specification for HTTP + based Content negotiation: + + * "application/statuslist+jwt" for Status List Token in JWT format + + * "application/statuslist+cwt" for Status List Token in CWT format + + The following is a non-normative example of a request for a Status + List Token with type application/statuslist+jwt: + + GET /statuslists/1 HTTP/1.1 + Host: example.com + Accept: application/statuslist+jwt + +8.2. Status List Response + + A successful response that contains a Status List Token MUST use an + HTTP status code in the 2xx range. + + A response MAY also choose to redirect the client to another URI + using an HTTP status code in the 3xx range, which clients SHOULD + follow. See Section 11.4 for security considerations on redirects. + + In the successful response, the Status Provider MUST use the + following content-type: + + * "application/statuslist+jwt" for Status List Token in JWT format + + * "application/statuslist+cwt" for Status List Token in CWT format + + In the case of "application/statuslist+jwt", the response MUST be of + type JWT and follow the rules of Section 5.1. In the case of + "application/statuslist+cwt", the response MUST be of type CWT and + follow the rules of Section 5.2. + + The body of such an HTTP response contains the raw Status List Token, + that means the binary encoding as defined in Section 9.2.1 of + [RFC8392] for a Status List Token in CWT format and the JWS Compact + Serialization form for a Status List Token in JWT format. Note that + while the examples for Status List Tokens in CWT format in this + document are provided in hex encoding, this is done purely for + readability; CWT format response bodies are "in binary". + + The HTTP response SHOULD use Content-Encoding (such as gzip) using + the content negotiation and encoding mechanisms as defined in + [RFC9110] for Status List Tokens in JWT format. + + + + + +Looker, et al. Expires 21 September 2026 [Page 24] + +Internet-Draft Token Status List (TSL) March 2026 + + + If caching-related HTTP headers are present in the HTTP response, + Relying Parties MUST prioritize the exp and ttl claims within the + Status List Token over the HTTP headers for determining caching + behavior. + + The following is a non-normative example of a response with a Status + List Token with type application/statuslist+jwt: + + HTTP/1.1 200 OK + Content-Type: application/statuslist+jwt + + eyJhbGciOiJFUzI1NiIsImtpZCI6IjEyIiwidHlwIjoic3RhdHVzbGlzdCtqd3QifQ.e + yJleHAiOjIyOTE3MjAxNzAsImlhdCI6MTY4NjkyMDE3MCwiaXNzIjoiaHR0cHM6Ly9le + GFtcGxlLmNvbSIsInN0YXR1c19saXN0Ijp7ImJpdHMiOjEsImxzdCI6ImVOcmJ1UmdBQ + WhjQlhRIn0sInN1YiI6Imh0dHBzOi8vZXhhbXBsZS5jb20vc3RhdHVzbGlzdHMvMSIsI + nR0bCI6NDMyMDB9.2lKUUNG503R9htu4aHAYi7vjmr3sgApbfoDvPrl65N3URUO1EYqq + Ql45Jfzd-Av4QzlKa3oVALpLwOEUOq-U_g + +8.3. Validation Rules + + Upon receiving a Referenced Token, a Relying Party MUST first perform + the validation of the Referenced Token - e.g., checking for expected + attributes, valid signature and expiration time. The processing + rules for Referenced Tokens (such as JWT or CWT) MUST precede any + evaluation of a Referenced Token's status. For example, if a token + is evaluated as being expired through the "exp" (Expiration Time) but + also has a status of 0x00 ("VALID"), the token is considered expired. + If the validation procedures for the Referenced Token determine it is + invalid, further procedures regarding Status List MUST NOT be + performed, e.g. fetching a Status List Token, unless the Referenced + Token procedures or the use case require further evaluation. + + If this validation is not successful, the Referenced Token MUST be + rejected. If the validation was successful, the Relying Party MUST + perform the following validation steps to evaluate the status of the + Referenced Token: + + 1. Check for the existence of a status claim, check for the + existence of a status_list claim within the status claim and + validate that the content of status_list adheres to the rules + defined in Section 6.2 for JOSE-based Referenced Tokens and + Section 6.3 for COSE-based Referenced Tokens. Other formats of + Referenced Tokens may define other encoding of the URI and index. + + 2. Resolve the Status List Token from the provided URI + + 3. Validate the Status List Token: + + + + +Looker, et al. Expires 21 September 2026 [Page 25] + +Internet-Draft Token Status List (TSL) March 2026 + + + a. Validate the Status List Token by following the rules defined + in Section 7.2 of [RFC7519] for JWTs and Section 7.2 of + [RFC8392] for CWTs. This step might require the resolution + of a public key as described in Section 11.3. + + b. Check for the existence of the required claims as defined in + Section 5.1 and Section 5.2 depending on the token type + + 4. All existing claims in the Status List Token MUST be checked + according to the rules in Section 5.1 and Section 5.2 + + a. The subject claim (sub or 2) of the Status List Token MUST be + equal to the uri claim in the status_list object of the + Referenced Token + + b. If the Relying Party has local policies regarding the + freshness of the Status List Token, it SHOULD check the + issued at claim (iat or 6) + + c. If the expiration time is defined (exp or 4), it MUST be + checked if the Status List Token is expired + + d. If the Relying Party is using a system for caching the Status + List Token, it SHOULD check the ttl claim of the Status List + Token and retrieve a fresh copy if (time status was resolved + + ttl < current time) + + 5. Decompress the Status List with a decompressor that is compatible + with DEFLATE [RFC1951] and ZLIB [RFC1950] + + 6. Retrieve the status value of the index specified in the + Referenced Token as described in Section 4. If the provided + index is out of bounds of the Status List, no statement about the + status of the Referenced Token can be made and the Referenced + Token MUST be rejected. + + 7. Check the status value as described in Section 7 + + If any of these checks fails, no statement about the status of the + Referenced Token can be made and the Referenced Token SHOULD be + rejected. + + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 26] + +Internet-Draft Token Status List (TSL) March 2026 + + +8.4. Historical Resolution + + By default, the status mechanism defined in this specification only + conveys information about the state of Referenced Tokens at the time + the Status List Token was issued. The validity period for this + information, as defined by the issuer, is explicitly stated by the + iat (issued at) and exp (expiration time) claims for JWT and their + corresponding ones for the CWT representation. If support for + historical status information is desired, this can be achieved by + extending with a timestamp the request for the Status List Token as + defined in Section 8.1. This feature has additional privacy + implications as described in Section 12.7. + + To obtain the Status List Token, the Relying Party MUST send an HTTP + GET request to the URI provided in the Referenced Token with the + additional query parameter time and its value being a unix timestamp, + forming the query component time= (see below for a non- + normative example of a request using such a query). The response for + a valid request SHOULD contain a Status List Token that was valid for + that specified time or an error. + + If the Server does not support the additional query parameter, it + SHOULD return a status code of 501 (Not Implemented) or if the + requested time is not supported it SHOULD return a status code of 404 + (Not Found). A Status List Token might be served via static file + hosting (e.g., leveraging a Content Delivery Network) that ignores + query parameters, which would result in the client requesting a + historical status list but receiving the current status list. Thus, + the client MUST reject a response unless the requested timestamp is + within the valid time of the returned token signaled via iat (6 for + CWT) and exp (4 for CWT). + + The following is a non-normative example of a GET request using the + time query parameter: + + GET /statuslists/1?time=1686925000 HTTP/1.1 + Host: example.com + Accept: application/statuslist+jwt + + The following is a non-normative example of a response for the above + Request: + + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 27] + +Internet-Draft Token Status List (TSL) March 2026 + + + HTTP/1.1 200 OK + Content-Type: application/statuslist+jwt + + eyJhbGciOiJFUzI1NiIsImtpZCI6IjEyIiwidHlwIjoic3RhdHVzbGlzdCtqd3QifQ.e + yJleHAiOjIyOTE3MjAxNzAsImlhdCI6MTY4NjkyMDE3MCwiaXNzIjoiaHR0cHM6Ly9le + GFtcGxlLmNvbSIsInN0YXR1c19saXN0Ijp7ImJpdHMiOjEsImxzdCI6ImVOcmJ1UmdBQ + WhjQlhRIn0sInN1YiI6Imh0dHBzOi8vZXhhbXBsZS5jb20vc3RhdHVzbGlzdHMvMSIsI + nR0bCI6NDMyMDB9.2lKUUNG503R9htu4aHAYi7vjmr3sgApbfoDvPrl65N3URUO1EYqq + Ql45Jfzd-Av4QzlKa3oVALpLwOEUOq-U_g + +9. Status List Aggregation + + Status List Aggregation is an optional mechanism offered by the + Issuer to publish a list of one or more Status List Tokens URIs, + allowing a Relying Party to fetch Status List Tokens provided by this + Issuer. This mechanism is intended to support fetching and caching + mechanisms and allow offline validation of the status of a Referenced + Token for a period of time. + + If a Relying Party encounters an error while validating one of the + Status List Tokens returned from the Status List Aggregation + endpoint, it SHOULD continue processing the other Status List Tokens. + + There are two options for a Relying Party to retrieve the Status List + Aggregation. An Issuer MAY support any of these mechanisms: + + * Issuer metadata: The Issuer of the Referenced Token publishes a + URI which links to Status List Aggregation, e.g. in publicly + available metadata of an issuance protocol + + * Status List Parameter: The Status Issuer includes an additional + claim in the Status List Token that contains the Status List + Aggregation URI. + + + + + + + + + + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 28] + +Internet-Draft Token Status List (TSL) March 2026 + + + +-----------------+ + | | + | Issuer Metadata | + | | + +---------+-------+ + batch of | + +-------------------+ | link within metadata + +-------------------+| link all v + +-------------------+||<-------+ +-------------------------+ + | ||<--------+ | | + | Status List Token |<---------+--| Status List Aggregation | + | |+ | | + +-------+-----------+ +-------------------------+ + | ^ + | | + | link by aggregation_uri | + +-------------------------------------+ + +9.1. Issuer Metadata + + The Issuer MAY link to the Status List Aggregation URI in metadata + that can be provided by different means like .well-known metadata as + is used commonly in OAuth as defined in [RFC8414], or within Issuer + certificates or trust lists (such as VICAL as defined in Annex C of + [ISO.mdoc]). If the Issuer is an OAuth Authorization Server + according to [RFC6749], it is RECOMMENDED to use the + status_list_aggregation_endpoint parameter within its metadata + defined by [RFC8414]. The Issuer MAY limit the Status List Tokens + listed by a Status List Aggregation to a particular type of + Referenced Token. + + The concrete implementation details depend on the specific ecosystem + and are out of scope of this specification. + +9.2. Status List Parameter + + The URI to the Status List Aggregation MAY be provided as the + optional parameter aggregation_uri in the Status List itself as + explained in Section 4.3 and Section 4.2 respectively. A Relying + Party may use this URI to retrieve an up-to-date list of relevant + Status Lists. + +9.3. Status List Aggregation Data Structure + + This section defines the structure for a JSON-encoded Status List + Aggregation: + + + + + +Looker, et al. Expires 21 September 2026 [Page 29] + +Internet-Draft Token Status List (TSL) March 2026 + + + * status_lists: REQUIRED. JSON array of strings that contains URIs + linking to Status List Tokens. + + The Status List Aggregation URI provides a list of Status List Token + URIs. This aggregation is in JSON and the returned media type MUST + be application/json. A Relying Party can iterate through this list + and fetch all Status List Tokens before encountering the specific URI + in a Referenced Token. + + The following is a non-normative example for media type application/ + json: + + { + "status_lists" : [ + "https://example.com/statuslists/1", + "https://example.com/statuslists/2", + "https://example.com/statuslists/3" + ] + } + +10. X.509 Certificate Extended Key Usage Extension + + [RFC5280] specifies the Extended Key Usage (EKU) X.509 certificate + extension for use on end entity certificates. The extension + indicates one or more purposes for which the certified public key is + valid. The EKU extension can be used in conjunction with the Key + Usage (KU) extension, which indicates the set of basic cryptographic + operations for which the certified key may be used. A certificate's + issuer explicitly delegates Status List Token signing authority by + issuing an X.509 certificate containing the KeyPurposeId defined + below in the extended key usage extension. Other specifications MAY + choose to re-use this OID for other status mechanisms under the + condition that they are registered in the "JWT Status Mechanisms" or + "CWT Status Mechanisms" registries. + + The following OID is defined for usage in the EKU extension: + + id-kp OBJECT IDENTIFIER ::= + { iso(1) identified-organization(3) dod(6) internet(1) + security(5) mechanisms(5) pkix(7) kp(3) } + + id-kp-oauthStatusSigning OBJECT IDENTIFIER ::= { id-kp TBD } + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 30] + +Internet-Draft Token Status List (TSL) March 2026 + + +11. Security Considerations + + Status List Tokens as defined in Section 5 only exist in + cryptographically secured containers which allow checking the + integrity and origin without relying on other factors such as + transport security or web PKI. + +11.1. Correct decoding and parsing of the encoded Status List + + Implementers should be particularly careful with the correct parsing + and decoding of the Status List. Incorrect implementations might + check the index on the wrong data or miscalculate the bit and byte + index leading to an erroneous status of the Referenced Token. + Beware, that bits are indexed (bit order) from least significant bit + to most significant bit (also called "right to left") while bytes are + indexed (byte order) in their natural incrementing byte order + (usually written for display purpose from left to right). Endianness + does not apply here because each status value fits within a single + byte. + + Implementations SHOULD verify correctness using the test vectors + given by this specification. + +11.2. Security Guidance for JWT and CWT + + A Status List Token in the JWT format MUST follow the security + considerations of [RFC7519] and the best current practices of + [RFC8725]. + + A Status List Token in the CWT format MUST follow the security + considerations of [RFC8392]. + +11.3. Key Resolution and Trust Management + + This specification does not mandate specific methods for key + resolution and trust management, however the following + recommendations are made for specifications, profiles, or ecosystems + that are planning to make use of the Status List mechanism: + + If the Issuer of the Referenced Token is the same entity as the + Status Issuer, then the same key that is embedded into the Referenced + Token may be used for the Status List Token. In this case the Status + List Token may use: + + * the same x5c value or an x5t, x5t#S256 or kid parameter + referencing to the same key as used in the Referenced Token for + JOSE. + + + + +Looker, et al. Expires 21 September 2026 [Page 31] + +Internet-Draft Token Status List (TSL) March 2026 + + + * the same x5chain value or an x5t or kid parameter referencing to + the same key as used in the Referenced Token for COSE. + + Alternatively, the Status Issuer may use the same web-based key + resolution that is used for the Referenced Token. In this case the + Status List Token may use: + + * an x5u, jwks, jwks_uri or kid parameter referencing to a key using + the same web-based resolution as used in the Referenced Token for + JOSE. + + * an x5u or kid parameter referencing to a key using the same web- + based resolution as used in the Referenced Token for COSE. + + +--------+ host keys +----------------------+ + | Issuer |----------+----->| .well-known metadata | + +---+----+ | +----------------------+ + | | + v update status | + +---------------+ | + | Status Issuer |---+ + +---+-----------+ + | + v provide Status List + +-----------------+ + | Status Provider | + +-----------------+ + + If the Issuer of the Referenced Token is a different entity than the + Status Issuer, then the keys used for the Status List Token may be + cryptographically linked, e.g. by a Certificate Authority through an + x.509 PKI. The certificate of the Issuer for the Referenced Token + and the Status Issuer should be issued by the same Certificate + Authority and the Status Issuer's certificate should utilize extended + key usage (Section 10). + + + + + + + + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 32] + +Internet-Draft Token Status List (TSL) March 2026 + + + +-----------------------+ + | Certificate Authority | + +---+-------------------+ + | + | authorize + | + | +--------+ + +--->| Issuer | + | +-+------+ + | | + | v update status + | +---------------+ + +--->| Status Issuer | + +-+-------------+ + | + v provide Status List + +-----------------+ + | Status Provider | + +-----------------+ + +11.4. Redirection 3xx + + HTTP clients that follow 3xx (Redirection) status codes MUST be aware + of the possible dangers of redirects, such as infinite redirection + loops, since they can be used for denial-of-service attacks on + clients. HTTP clients MUST follow the guidance provided in + Section 15.4 of [RFC9110] for handling redirects. + +11.5. Expiration and Caching + + Expiration and caching information is conveyed via the exp and ttl + claims as explained in Section 13.7. Clients SHOULD check that both + values are within reasonable ranges before requesting new Status List + Tokens based on these values to prevent accidentally creating + unreasonable amounts of requests for a specific URL. Status Issuers + could accidentally or maliciously use this mechanism to effectively + DDoS the contained URL of the Status Provider. + + Reasonable values for both claims highly depend on the use-case + requirements and clients should be configured with lower/upper bounds + for these values that fit their respective use-cases. + + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 33] + +Internet-Draft Token Status List (TSL) March 2026 + + +11.6. Status List Token Protection + + This specification allows both, digital signatures using asymmetric + cryptography, and Message Authentication Codes (MAC) to be used to + protect Status List Tokens. Implementers should only use MACs to + secure the integrity of Status List Tokens if they fully understand + the risks of MACs when compared to digital signatures and especially + the requirements of their use-case scenarios. These use-cases + typically represent deployments where Status Issuer and Relying Party + have a trust relationship and the possibility to securely exchange + keys out of band or are the same entity and no other entity needs to + verify the Status List Token. We expect most deployments to use + digital signatures for the protection of Status List Tokens and + implementers SHOULD default to digital signatures if they are unsure. + +12. Privacy Considerations + +12.1. Observability of Issuers + + The main privacy consideration for a Status List, especially in the + context of the Issuer-Holder-Verifier model [RFC9901], is to prevent + the Issuer from tracking the usage of the Referenced Token when the + status is being checked. If an Issuer offers status information by + referencing a specific token, this would enable the Issuer to create + a profile for the issued token by correlating the date and identity + of Relying Parties, that are requesting the status. + + The Status List approaches these privacy implications by integrating + the status information of many Referenced Tokens into the same list. + Therefore, the Issuer does not learn for which Referenced Token the + Relying Party is requesting the Status List. The privacy of the + Holder is protected by the anonymity within the set of Referenced + Tokens in the Status List, also called herd privacy. This limits the + possibilities of tracking by the Issuer. + + The herd privacy is depending on the number of entities within the + Status List called its size. A larger size results in better privacy + but also impacts the performance as more data has to be transferred + to read the Status List. + + Additionally, the Issuer may analyse data from the HTTP request to + identify the Relying Party, e.g. through the sender's IP address. + + This behaviour may be mitigated by: + + * private relay protocols or other mechanisms hiding the original + sender like [RFC9458]. + + + + +Looker, et al. Expires 21 September 2026 [Page 34] + +Internet-Draft Token Status List (TSL) March 2026 + + + * using trusted Third Party Hosting, see Section 12.6. + +12.2. Issuer Tracking of Referenced Tokens + + An Issuer could maliciously or accidentally bypass the privacy + benefits of the herd privacy by either: + + * Generating a unique Status List for every Referenced Token. By + these means, the Issuer could maintain a mapping between + Referenced Tokens and Status Lists and thus track the usage of + Referenced Tokens by utilizing this mapping for the incoming + requests. + + * Encoding a unique URI in each Referenced Token which points to the + underlying Status List. This may involve using URI components + such as query parameters, unique path segments, or fragments to + make the URI unique. + + This malicious behavior can be detected by Relying Parties that + request large amounts of Referenced Tokens by comparing the number of + different Status Lists and their sizes with the volume of Referenced + Tokens being verified. + +12.3. Observability of Relying Parties + + Once the Relying Party receives the Referenced Token, the Relying + Party can request the Status List through the provided uri parameter + and can validate the Referenced Token's status by looking up the + corresponding index. However, the Relying Party may persistently + store the uri and index of the Referenced Token to request the Status + List again at a later time. By doing so regularly, the Relying Party + may create a profile of the Referenced Token's validity status. This + behaviour may be intended as a feature, e.g. for an identity proofing + (e.g. Know-Your-Customer process in finance industry) that requires + regular validity checks, but might also be abused in cases where this + is not intended and unknown to the Holder, e.g. profiling the + suspension of an employee credential. + + This behaviour could be mitigated by: + + * regular re-issuance of the Referenced Token, see Section 13.2. + + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 35] + +Internet-Draft Token Status List (TSL) March 2026 + + +12.4. Observability of Outsiders + + Outside actors may analyse the publicly available Status Lists to get + information on the internal processes of the Issuer and its related + business, e.g. number of customers or clients. This data may allow + inferences on the total number of issued Referenced Tokens and the + revocation rate. Additionally, actors may regularly fetch this data + or use the historic data functionality to learn how these numbers + change over time. + + This behaviour could be mitigated by: + + * disabling the historical data feature Section 8.4 + + * disabling the Status List Aggregation Section 9 + + * choosing non-sequential, pseudo-random or random indices + + * using decoy entries to obfuscate the real number of Referenced + Tokens within a Status List + + * choosing to deploy and utilize multiple Status Lists + simultaneously + +12.5. Unlinkability + + The tuple of uri and index inside the Referenced Token are unique and + therefore is traceable data. + +12.5.1. Cross-party Collusion + + Two or more colluding parties (e.g Relying Parties and or the Status + Issuer) may link two transactions involving the same Referenced Token + by comparing the status claims of received Referenced Tokens and + therefore determine that they have interacted with the same Holder. + + To avoid privacy risks of this possible collusion, it is RECOMMENDED + that Issuers provide the ability to issue batches of one-time-use + Referenced Tokens, enabling Holders to use them in a single + interaction with a Relying Party before discarding. See Section 13.2 + to avoid further correlatable information by the values of uri and + idx, Status Issuers are RECOMMENDED to: + + * choose non-sequential, pseudo-random or random indices + + * use decoy entries to obfuscate the real number of Referenced + Tokens within a Status List + + + + +Looker, et al. Expires 21 September 2026 [Page 36] + +Internet-Draft Token Status List (TSL) March 2026 + + + * choose to deploy and utilize multiple Status Lists simultaneously + +12.6. External Status Provider for Privacy + + If the roles of the Status Issuer and the Status Provider are + performed by different entities, this may give additional privacy + assurances as the Issuer has no means to identify the Relying Party + or its request. + + Third-Party hosting may also allow for greater scalability, as the + Status List Tokens may be served by operators with greater resources, + like CDNs, while still ensuring authenticity and integrity of Token + Status List, as it is signed by the Status Issuer. + +12.7. Historical Resolution + + By default, this specification only supports providing Status List + information for the most recent status information and does not allow + the lookup of historical information like a validity state at a + specific point in time. There exists optional support for a query + parameter that allows this kind of historic lookup as described in + Section 8.4. There are scenarios where such a functionality is + necessary, but this feature should only be implemented when the + scenario and the consequences of enabling historical resolution are + fully understood. + + There are strong privacy concerns that have to be carefully taken + into consideration when providing a mechanism that allows historic + requests for status information - see Section 12.3 for more details. + Support for this functionality is optional and Implementers are + RECOMMENDED to not support historic requests unless there are strong + reasons to do so and after carefully considering the privacy + implications. + +12.8. Status Types + + As previously explained, there is the potential risk of observability + by Relying Parties (see Section 12.3) and Outsiders (see + Section 12.4). That means that any Status Type that transports + information beyond the routine statuses VALID and INVALID about a + Referenced Token can leak information to other parties. This + document defines one additional Status Type with "SUSPENDED" that + conveys such additional information, but in practice all statuses + other than VALID and INVALID are likely to contain information with + privacy implications. + + + + + + +Looker, et al. Expires 21 September 2026 [Page 37] + +Internet-Draft Token Status List (TSL) March 2026 + + + Ecosystems that want to use other Status Types than "VALID" and + "INVALID" should consider the possible leakage of data and profiling + possibilities before doing so and evaluate if revocation and re- + issuance might be a better fit for their use-case. + +13. Operational Considerations + +13.1. Token Lifecycle + + The lifetime of a Status List Token depends on the lifetime of its + Referenced Tokens. Once all Referenced Tokens are expired, the + Issuer may stop serving the Status List Token. + +13.2. Linkability Mitigation + + Referenced Tokens may be regularly re-issued to mitigate the + linkability of presentations to Relying Parties. In this case, every + re-issued Referenced Token MUST have a fresh Status List entry in + order to prevent the index value from becoming a possible source of + correlation. + + Referenced Tokens may also be issued in batches and be presented by + Holders in a one-time-use policy to avoid linkability. In this case, + every Referenced Token MUST have a dedicated Status List entry and + MAY be spread across multiple Status List Tokens. Batch revocation + of a batch of Referenced Tokens might reveal that they are all + members of the same batch. + + Beware that this mechanism solves linkability issues between Relying + Parties but does not prevent traceability by Issuers. + +13.3. Default Values and Double Allocation + + The Status Issuer is RECOMMENDED to initialize the Status List byte + array with a default value provided as an initialization parameter by + the Issuer of the Referenced Token. The Issuer is RECOMMENDED to use + a default value that represents the most common value for its + Referenced Tokens to avoid an update during issuance (usually 0x00, + VALID). This preserves the benefits from compression and effectively + hides the number of managed Referenced Tokens since an unused index + value can not be distinguished from a valid Referenced Token. + + The Status Issuer is RECOMMENDED to prevent double allocation, i.e. + re-using the same uri and idx for multiple Referenced Tokens (since + uri and idx form a unique identifier that might be used for tracking, + see Section 12 for more details). The Status Issuer MUST prevent any + unintended double allocation. + + + + +Looker, et al. Expires 21 September 2026 [Page 38] + +Internet-Draft Token Status List (TSL) March 2026 + + +13.4. Status List Size + + The storage and transmission size of the Status Issuer's Status List + Tokens depend on: + + * the size of the Status List, i.e. the number of Referenced Tokens + + * the revocation rate and distribution of the Status List data (due + to compression, revocation rates close to 0% or 100% lead to the + lowest sizes while revocation rates closer to 50% and random + distribution lead to the highest sizes) + + * the lifetime of Referenced Tokens (shorter lifetimes allows for + earlier retirement of Status List Tokens) + + The Status List Issuer may increase the size of a Status List if it + requires indices for additional Referenced Tokens. It is RECOMMENDED + that the size of a Status List in bits is divisible in bytes (8 bits) + without a remainder, i.e. size-in-bits % 8 = 0. + + The Status List Issuer may divide its Referenced Tokens up into + multiple Status Lists to reduce the transmission size of an + individual Status List Token. This may be useful for ecosystems + where some entities operate in constrained environments, e.g. for + mobile internet or embedded devices. The Status List Issuer may + organize the Status List Tokens depending on the Referenced Token's + expiry date to align their lifecycles and allow for easier retiring + of Status List Tokens, however the Status Issuer must be aware of + possible privacy risks due to correlations. + +13.5. External Status Issuer + + If the roles of the Issuer of the Referenced Token and the Status + Issuer are performed by different entities, this may allow for use + cases that require revocation of Referenced Tokens to be managed by + different entities, e.g. for regulatory or privacy reasons. In this + scenario both parties must align on: + + * the key and trust management as described in Section 11.3 + + * parameters for the Status List + + - number of bits for the Status Type as described in Section 4 + + - update cycle of the Issuer used for ttl in the Status List + Token as described in Section 5 + + + + + +Looker, et al. Expires 21 September 2026 [Page 39] + +Internet-Draft Token Status List (TSL) March 2026 + + +13.6. External Status Provider for Scalability + + If the roles of the Status Issuer and the Status Provider are + performed by different entities, this may allow for greater + scalability, as the Status List Tokens may be served by operators + with greater resources, like CDNs. At the same time the authenticity + and integrity of Token Status List is still guaranteed, as it is + signed by the Status Issuer. + +13.7. Status List Update Interval and Caching + + Status Issuers have two options to communicate their update interval + policy for the status of their Referenced Tokens: + + * the exp claim specifies an absolute timestamp, marking the point + in time when the Status List expires and MUST NOT be relied upon + any longer + + * the ttl claim represents a duration to be interpreted relative to + the time the Status List is fetched, indicating when a new version + of the Status List may be available + + Both ttl and exp are RECOMMENDED to be used by the Status Issuer. + + When fetching a Status List Token, Relying Parties must carefully + evaluate how long a Status List is cached for. Collectively the iat, + exp and ttl claims when present in a Status List Token communicate + how long a Status List should be cached and should be considered + valid for. Relying Parties have different options for caching the + Status List: + + * After time of fetching, the Relying Party caches the Status List + for time duration of ttl before making checks for updates. This + method is RECOMMENDED to distribute the load for the Status + Provider. + + * After initial fetching, the Relying Party checks for updates at + time of iat + ttl. This method ensures the most up-to-date + information for critical use cases. The Relying Party should + account a minimal offset due to the signing and distribution + process of the Status Issuer. + + * If no ttl is given, then Relying Party SHOULD check for updates + latest after the time of exp. + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 40] + +Internet-Draft Token Status List (TSL) March 2026 + + + Ultimately, it's the Relying Parties decision how often to check for + updates, ecosystems may define their own guidelines and policies for + updating the Status List information. Clients should ensure that exp + and ttl are within reasonable bounds before creating requests to get + a fresh Status List Token (see Section 11.5 for more details). + + The following diagram illustrates the relationship between these + claims and how they are designed to influence caching: + + Time of Check for Check for Check for + Fetching updates updates updates + + iat | | | | exp + | | | | + | | | | | | + | | | | | | + | | | | | | + | | | | | | + | | ttl | ttl | ttl | | + | | -------------> | -------------> | -------------> | --> | + | | | | | | + | | | | | | + | | + --+---------------------------------------------------------------+--> + | | + +13.8. Relying Parties avoiding correlatable Information + + If the Relying Party does not require the Referenced Token or the + Status List Token for further processing, it is RECOMMENDED to delete + correlatable information, in particular: + + * the status claim in the Referenced Token (after the validation) + + * the Status List Token itself (after expiration or update) + + The Relying Party should instead only keep the needed fields from the + Referenced Token. + +13.9. Status List Formats + + This specification defines 2 different token formats of the Status + List: + + * JWT + + * CWT + + + + +Looker, et al. Expires 21 September 2026 [Page 41] + +Internet-Draft Token Status List (TSL) March 2026 + + + This specification states no requirements to not mix different + formats like a CBOR based Referenced Token using a JWT for the Status + List, but the expectation is that within an ecosystem, a choice for + specific formats is made. Within such an ecosystem, only support for + those selected variants is required and implementations should know + what to expect via a profile. + +14. IANA Considerations + +14.1. JSON Web Token Claims Registration + + This specification requests registration of the following Claims in + the IANA "JSON Web Token Claims" registry [IANA.JWT] established by + [RFC7519]. + +14.1.1. Registry Contents + + * Claim Name: status + + * Claim Description: A JSON object containing a reference to a + status mechanism from the JWT Status Mechanisms Registry. + + * Change Controller: IETF + + * Specification Document(s): Section 6.1 of this specification + + + + * Claim Name: status_list + + * Claim Description: A JSON object containing up-to-date status + information on multiple tokens using the Token Status List + mechanism. + + * Change Controller: IETF + + * Specification Document(s): Section 5.1 of this specification + + + + * Claim Name: ttl + + * Claim Description: Time to Live + + * Change Controller: IETF + + * Specification Document(s): Section 5.1 of this specification + + + + +Looker, et al. Expires 21 September 2026 [Page 42] + +Internet-Draft Token Status List (TSL) March 2026 + + +14.2. JWT Status Mechanisms Registry + + This specification establishes the IANA "JWT Status Mechanisms" + registry for JWT "status" member values and adds it to the "JSON Web + Token (JWT)" registry group at https://www.iana.org/assignments/jwt. + The registry records the status mechanism member and a reference to + the specification that defines it. + + JWT Status Mechanisms are registered by Specification Required + [RFC8126] after a three-week review period on the jwt-reg- + review@ietf.org mailing list, on the advice of one or more Designated + Experts. + + Registration requests sent to the mailing list for review should use + an appropriate subject (e.g., "Request to register JWT Status + Mechanism: example"). + + Within the review period, the Designated Expert(s) will either + approve or deny the registration request, communicating this decision + to the review list and IANA. Denials should include an explanation + and, if applicable, suggestions as to how to make the request + successful. + + IANA must only accept registry updates from the Designated Expert(s) + and should direct all requests for registration to the review mailing + list. + +14.2.1. Registration Template + + Status Mechanism Value: + + The name requested (e.g., "status_list"). The name is case- + sensitive. Names may not match other registered names in a case- + insensitive manner unless the Designated Experts state that there + is a compelling reason to allow an exception. + + Status Mechanism Description: + + Brief description of the status mechanism. + + Change Controller: + + For IETF Stream RFCs, list the IETF. For others, give the name of + the responsible party. Other details (e.g., postal address, email + address, home page URI) may also be included. + + Specification Document(s): + + + + +Looker, et al. Expires 21 September 2026 [Page 43] + +Internet-Draft Token Status List (TSL) March 2026 + + + Reference to the document or documents that specify the parameter, + preferably including URIs that can be used to retrieve copies of + the documents. An indication of the relevant sections may also be + included but is not required. + +14.2.2. Initial Registry Contents + + * Status Mechanism Value: status_list + + * Status Mechanism Description: A Token Status List containing up- + to-date status information on multiple tokens. + + * Change Controller: IETF + + * Specification Document(s): Section 6.2 of this specification + +14.3. CBOR Web Token Claims Registration + + This specification requests registration of the following Claims in + the IANA "CBOR Web Token (CWT) Claims" registry [IANA.CWT] + established by [RFC8392]. + +14.3.1. Registry Contents + + + + * Claim Name: status + + * Claim Description: A CBOR structure containing a reference to a + status mechanism from the CWT Status Mechanisms Registry. + + * JWT Claim Name: status + + * Claim Key: TBD (requested assignment 65535) + + * Claim Value Type: map + + * Change Controller: IETF + + * Reference: Section 6.1 of this specification + + + + * Claim Name: status_list + + * Claim Description: A CBOR structure containing up-to-date status + information on multiple tokens using the Token Status List + mechanism. + + + +Looker, et al. Expires 21 September 2026 [Page 44] + +Internet-Draft Token Status List (TSL) March 2026 + + + * JWT Claim Name: status_list + + * Claim Key: TBD (requested assignment 65533) + + * Claim Value Type: map + + * Change Controller: IETF + + * Specification Document(s): Section 5.2 of this specification + + + + * Claim Name: ttl + + * Claim Description: Time to Live + + * JWT Claim Name: ttl + + * Claim Key: TBD (requested assignment 65534) + + * Claim Value Type: unsigned integer + + * Change Controller: IETF + + * Specification Document(s): Section 5.2 of this specification + +14.4. CWT Status Mechanisms Registry + + This specification establishes the IANA "CWT Status Mechanisms" + registry for CWT "status" member values and adds it to the "CBOR Web + Token (CWT) Claims" registry group at + https://www.iana.org/assignments/cwt. The registry records the + status mechanism member and a reference to the specification that + defines it. + + CWT Status Mechanisms are registered by Specification Required + [RFC8126] after a three-week review period on the cwt-reg- + review@ietf.org mailing list, on the advice of one or more Designated + Experts. However, to allow for the allocation of names prior to + publication, the Designated Expert(s) may approve registration once + they are satisfied that such a specification will be published. + + Registration requests sent to the mailing list for review should use + an appropriate subject (e.g., "Request to register CWT Status + Mechanism: example"). + + + + + + +Looker, et al. Expires 21 September 2026 [Page 45] + +Internet-Draft Token Status List (TSL) March 2026 + + + Within the review period, the Designated Expert(s) will either + approve or deny the registration request, communicating this decision + to the review list and IANA. Denials should include an explanation + and, if applicable, suggestions as to how to make the request + successful. + + IANA must only accept registry updates from the Designated Expert(s) + and should direct all requests for registration to the review mailing + list. + +14.4.1. Registration Template + + Status Mechanism Value: + + The name requested (e.g., "status_list"). The name is case- + sensitive. Names may not match other registered names in a case- + insensitive manner unless the Designated Experts state that there + is a compelling reason to allow an exception. + + Status Mechanism Description: + + Brief description of the status mechanism. + + Change Controller: + + For IETF Stream RFCs, list the IETF. For others, give the name of + the responsible party. Other details (e.g., postal address, email + address, home page URI) may also be included. + + Specification Document(s): + + Reference to the document or documents that specify the parameter, + preferably including URIs that can be used to retrieve copies of + the documents. An indication of the relevant sections may also be + included but is not required. + +14.4.2. Initial Registry Contents + + * Status Mechanism Value: status_list + + * Status Mechanism Description: A Token Status List containing up- + to-date status information on multiple tokens. + + * Change Controller: IETF + + * Specification Document(s): Section 6.3 of this specification + + + + + +Looker, et al. Expires 21 September 2026 [Page 46] + +Internet-Draft Token Status List (TSL) March 2026 + + +14.5. OAuth Status Types Registry + + This specification establishes the IANA "OAuth Status Types" registry + for Status List values and adds it to the "OAuth Parameters" registry + group at https://www.iana.org/assignments/oauth-parameters. The + registry records a human-readable label, the bit representation and a + common description for it. + + Status Types are registered by Specification Required [RFC8126] after + a two-week review period on the oauth-ext-review@ietf.org mailing + list, on the advice of one or more Designated Experts. However, to + allow for the allocation of names prior to publication, the + Designated Expert(s) may approve registration once they are satisfied + that such a specification will be published. + + Registration requests sent to the mailing list for review should use + an appropriate subject (e.g., "Request to register Status Type name: + example"). + + Within the review period, the Designated Expert(s) will either + approve or deny the registration request, communicating this decision + to the review list and IANA. Denials should include an explanation + and, if applicable, suggestions as to how to make the request + successful. + + IANA must only accept registry updates from the Designated Expert(s) + and should direct all requests for registration to the review mailing + list. + +14.5.1. Registration Template + + Status Type Name: + + The name is a human-readable case-insensitive label for the Status + Type that helps to talk about the status of Referenced Token in + common language. + + Status Type Description: + + Brief description of the Status Type and optional examples. + + Status Type value: + + The bit representation of the Status Type in a byte hex + representation. Valid Status Type values range from 0x00-0xFF. + Values are filled up with zeros if they have less than 8 bits. + + Change Controller: + + + +Looker, et al. Expires 21 September 2026 [Page 47] + +Internet-Draft Token Status List (TSL) March 2026 + + + For IETF Stream RFCs, list the IETF. For others, give the name of + the responsible party. Other details (e.g., postal address, email + address, home page URI) may also be included. + + Specification Document(s): + + Reference to the document or documents that specify the parameter, + preferably including URIs that can be used to retrieve copies of + the documents. An indication of the relevant sections may also be + included but is not required. + +14.5.2. Initial Registry Contents + + * Status Type Name: VALID + + * Status Type Description: The status of the Referenced Token is + valid, correct or legal. + + * Status Type value: 0x00 + + * Change Controller: IETF + + * Specification Document(s): Section 7 of this specification + + + + * Status Type Name: INVALID + + * Status Type Description: The status of the Referenced Token is + revoked, annulled, taken back, recalled or cancelled. + + * Status Type value: 0x01 + + * Change Controller: IETF + + * Specification Document(s): Section 7 of this specification + + + + * Status Type Name: SUSPENDED + + * Status Type Description: The status of the Referenced Token is + temporarily invalid, hanging or debarred from privilege. This + state is usually temporary. + + * Status Type value: 0x02 + + * Change Controller: IETF + + + +Looker, et al. Expires 21 September 2026 [Page 48] + +Internet-Draft Token Status List (TSL) March 2026 + + + * Specification Document(s): Section 7 of this specification + + + + * Status Type Name: APPLICATION_SPECIFIC + + * Status Type Description: The status of the Referenced Token is + application specific. + + * Status Type value: 0x03 + + * Change Controller: IETF + + * Specification Document(s): Section 7 of this specification + + + + * Status Type Name: APPLICATION_SPECIFIC + + * Status Type Description: The status of the Referenced Token is + application specific. + + * Status Type value: 0x0C-0x0F + + * Change Controller: IETF + + * Specification Document(s): Section 7 of this specification + + + +14.6. OAuth Parameters Registration + + This specification requests registration of the following values in + the IANA "OAuth Authorization Server Metadata" registry + [IANA.OAuth.Params] established by [RFC8414]. + + * Metadata Name: status_list_aggregation_endpoint + + * Metadata Description: URL of the Authorization Server aggregating + OAuth Token Status List URLs for token status management. + + * Change Controller: IESG + + * Reference: Section 9 of this specification + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 49] + +Internet-Draft Token Status List (TSL) March 2026 + + +14.7. Media Type Registration + + This section requests registration of the following media types + [RFC2046] in the "Media Types" registry [IANA.MediaTypes] in the + manner described in [RFC6838]. + + To indicate that the content is a JWT-based Status List: + + * Type name: application + + * Subtype name: statuslist+jwt + + * Required parameters: n/a + + * Optional parameters: n/a + + * Encoding considerations: See Section 5.1 of this specification + + * Security considerations: See Section 11 of this specification + + * Interoperability considerations: n/a + + * Published specification: this specification + + * Applications that use this media type: Applications using this + specification for updated status information of tokens + + * Fragment identifier considerations: n/a + + * Additional information: n/a + + * Person & email address to contact for further information: OAuth + WG mailing list, oauth@ietf.org + + * Intended usage: COMMON + + * Restrictions on usage: none + + * Author: OAuth WG mailing list, oauth@ietf.org + + * Change controller: IETF + + * Provisional registration? No + + To indicate that the content is a CWT-based Status List: + + * Type name: application + + + + +Looker, et al. Expires 21 September 2026 [Page 50] + +Internet-Draft Token Status List (TSL) March 2026 + + + * Subtype name: statuslist+cwt + + * Required parameters: n/a + + * Optional parameters: n/a + + * Encoding considerations: See Section 5.2 of this specification + + * Security considerations: See Section 11 of this specification + + * Interoperability considerations: n/a + + * Published specification: this specification + + * Applications that use this media type: Applications using this + specification for updated status information of tokens + + * Fragment identifier considerations: n/a + + * Additional information: n/a + + * Person & email address to contact for further information: OAuth + WG mailing list, oauth@ietf.org + + * Intended usage: COMMON + + * Restrictions on usage: none + + * Author: OAuth WG mailing list, oauth@ietf.org + + * Change controller: IETF + + * Provisional registration? No + +14.8. CoAP Content-Format Registrations + + IANA is requested to register the following Content-Format numbers in + the "CoAP Content-Formats" sub-registry, within the "Constrained + RESTful Environments (CoRE) Parameters" Registry [IANA.Core.Params]: + + * Content Type: application/statuslist+cwt + + * Content Coding: - + + * ID: TBD + + * Reference: this specification + + + + +Looker, et al. Expires 21 September 2026 [Page 51] + +Internet-Draft Token Status List (TSL) March 2026 + + +14.9. X.509 Certificate Extended Key Purpose OID Registration + + IANA is requested to register the following OID "1.3.6.1.5.5.7.3.TBD" + with a description of "id-kp-oauthStatusSigning" in the "SMI Security + for PKIX Extended Key Purpose" registry (1.3.6.1.5.5.7.3). This OID + is defined in Section 10. + + IANA is requested to register the following OID "1.3.6.1.5.5.7.0.TBD" + with a description of "id-mod-oauth-status-signing-eku" in the "SMI + Security for PKIX Module Identifier" registry (1.3.6.1.5.5.7.0). + This OID is defined in Appendix A. + +15. Acknowledgments + + We would like to thank Andrii Deinega, Brian Campbell, Dan Moore, + Denis Pinkas, Filip Skokan, Francesco Marino, Giuseppe De Marco, + Hannes Tschofenig, Kristina Yasuda, Markus Kreusch, Martijn Haring, + Michael B. Jones, Micha Kraus, Michael Schwartz, Mike Prorock, Mirko + Mollik, Oliver Terbu, Orie Steele, Rifaat Shekh-Yusef, Rohan Mahy, + Takahiko Kawasaki, Timo Glastra and Torsten Lodderstedt + + for their valuable contributions, discussions and feedback to this + specification. + +16. References + +16.1. Normative References + + [CORS] WHATWG, "Fetch Living Standard", n.d., + . + + [RFC1950] Deutsch, P. and J. Gailly, "ZLIB Compressed Data Format + Specification version 3.3", RFC 1950, + DOI 10.17487/RFC1950, May 1996, + . + + [RFC1951] Deutsch, P., "DEFLATE Compressed Data Format Specification + version 1.3", RFC 1951, DOI 10.17487/RFC1951, May 1996, + . + + [RFC2046] Freed, N. and N. Borenstein, "Multipurpose Internet Mail + Extensions (MIME) Part Two: Media Types", RFC 2046, + DOI 10.17487/RFC2046, November 1996, + . + + + + + +Looker, et al. Expires 21 September 2026 [Page 52] + +Internet-Draft Token Status List (TSL) March 2026 + + + [RFC2119] Bradner, S., "Key words for use in RFCs to Indicate + Requirement Levels", BCP 14, RFC 2119, + DOI 10.17487/RFC2119, March 1997, + . + + [RFC3986] Berners-Lee, T., Fielding, R., and L. Masinter, "Uniform + Resource Identifier (URI): Generic Syntax", STD 66, + RFC 3986, DOI 10.17487/RFC3986, January 2005, + . + + [RFC5280] Cooper, D., Santesson, S., Farrell, S., Boeyen, S., + Housley, R., and W. Polk, "Internet X.509 Public Key + Infrastructure Certificate and Certificate Revocation List + (CRL) Profile", RFC 5280, DOI 10.17487/RFC5280, May 2008, + . + + [RFC6838] Freed, N., Klensin, J., and T. Hansen, "Media Type + Specifications and Registration Procedures", BCP 13, + RFC 6838, DOI 10.17487/RFC6838, January 2013, + . + + [RFC7515] Jones, M., Bradley, J., and N. Sakimura, "JSON Web + Signature (JWS)", RFC 7515, DOI 10.17487/RFC7515, May + 2015, . + + [RFC7519] Jones, M., Bradley, J., and N. Sakimura, "JSON Web Token + (JWT)", RFC 7519, DOI 10.17487/RFC7519, May 2015, + . + + [RFC8126] Cotton, M., Leiba, B., and T. Narten, "Guidelines for + Writing an IANA Considerations Section in RFCs", BCP 26, + RFC 8126, DOI 10.17487/RFC8126, June 2017, + . + + [RFC8174] Leiba, B., "Ambiguity of Uppercase vs Lowercase in RFC + 2119 Key Words", BCP 14, RFC 8174, DOI 10.17487/RFC8174, + May 2017, . + + [RFC8259] Bray, T., Ed., "The JavaScript Object Notation (JSON) Data + Interchange Format", STD 90, RFC 8259, + DOI 10.17487/RFC8259, December 2017, + . + + [RFC8392] Jones, M., Wahlstroem, E., Erdtman, S., and H. Tschofenig, + "CBOR Web Token (CWT)", RFC 8392, DOI 10.17487/RFC8392, + May 2018, . + + + + + +Looker, et al. Expires 21 September 2026 [Page 53] + +Internet-Draft Token Status List (TSL) March 2026 + + + [RFC8725] Sheffer, Y., Hardt, D., and M. Jones, "JSON Web Token Best + Current Practices", BCP 225, RFC 8725, + DOI 10.17487/RFC8725, February 2020, + . + + [RFC8949] Bormann, C. and P. Hoffman, "Concise Binary Object + Representation (CBOR)", STD 94, RFC 8949, + DOI 10.17487/RFC8949, December 2020, + . + + [RFC9052] Schaad, J., "CBOR Object Signing and Encryption (COSE): + Structures and Process", STD 96, RFC 9052, + DOI 10.17487/RFC9052, August 2022, + . + + [RFC9110] Fielding, R., Ed., Nottingham, M., Ed., and J. Reschke, + Ed., "HTTP Semantics", STD 97, RFC 9110, + DOI 10.17487/RFC9110, June 2022, + . + + [RFC9596] Jones, M.B. and O. Steele, "CBOR Object Signing and + Encryption (COSE) "typ" (type) Header Parameter", + RFC 9596, DOI 10.17487/RFC9596, June 2024, + . + + [X.680] International Telecommunications Union, "Information + Technology - Abstract Syntax Notation One (ASN.1): + Specification of basic notation", February 2021. + + [X.690] International Telecommunications Union, "Information + Technology - ASN.1 encoding rules: Specification of Basic + Encoding Rules (BER), Canonical Encoding Rules (CER) and + Distinguished Encoding Rules (DER)", February 2021. + +16.2. Informative References + + [IANA.Core.Params] + IANA, "Constrained RESTful Environments (CoRE) + Parameters", n.d., . + + [IANA.CWT] IANA, "CBOR Web Token (CWT) Claims", n.d., + . + + [IANA.JWT] IANA, "JSON Web Token Claims", n.d., + . + + + + + +Looker, et al. Expires 21 September 2026 [Page 54] + +Internet-Draft Token Status List (TSL) March 2026 + + + [IANA.MediaTypes] + IANA, "Media Types", n.d., + . + + [IANA.OAuth.Params] + IANA, "OAuth Authorization Server Metadata", n.d., + . + + [ISO.mdoc] ISO/IEC JTC 1/SC 17, "ISO/IEC 18013-5:2021 ISO-compliant + driving licence", n.d., + . + + [RFC6749] Hardt, D., Ed., "The OAuth 2.0 Authorization Framework", + RFC 6749, DOI 10.17487/RFC6749, October 2012, + . + + [RFC7662] Richer, J., Ed., "OAuth 2.0 Token Introspection", + RFC 7662, DOI 10.17487/RFC7662, October 2015, + . + + [RFC7800] Jones, M., Bradley, J., and H. Tschofenig, "Proof-of- + Possession Key Semantics for JSON Web Tokens (JWTs)", + RFC 7800, DOI 10.17487/RFC7800, April 2016, + . + + [RFC8414] Jones, M., Sakimura, N., and J. Bradley, "OAuth 2.0 + Authorization Server Metadata", RFC 8414, + DOI 10.17487/RFC8414, June 2018, + . + + [RFC8610] Birkholz, H., Vigano, C., and C. Bormann, "Concise Data + Definition Language (CDDL): A Notational Convention to + Express Concise Binary Object Representation (CBOR) and + JSON Data Structures", RFC 8610, DOI 10.17487/RFC8610, + June 2019, . + + [RFC9458] Thomson, M. and C. A. Wood, "Oblivious HTTP", RFC 9458, + DOI 10.17487/RFC9458, January 2024, + . + + [RFC9562] Davis, K., Peabody, B., and P. Leach, "Universally Unique + IDentifiers (UUIDs)", RFC 9562, DOI 10.17487/RFC9562, May + 2024, . + + + + + + +Looker, et al. Expires 21 September 2026 [Page 55] + +Internet-Draft Token Status List (TSL) March 2026 + + + [RFC9901] Fett, D., Yasuda, K., and B. Campbell, "Selective + Disclosure for JSON Web Tokens", RFC 9901, + DOI 10.17487/RFC9901, November 2025, + . + + [SD-CWT] Prorock, M., Steele, O., Birkholz, H., and R. Mahy, + "Selective Disclosure CBOR Web Tokens (SD-CWT)", Work in + Progress, Internet-Draft, draft-ietf-spice-sd-cwt-07, 2 + March 2026, . + + [SD-JWT.VC] + Terbu, O., Fett, D., and B. Campbell, "SD-JWT-based + Verifiable Digital Credentials (SD-JWT VC)", Work in + Progress, Internet-Draft, draft-ietf-oauth-sd-jwt-vc-15, + 26 February 2026, . + + [smith2020let] + Smith, T., Dickinson, L., and K. Seamons, "Let's revoke: + Scalable global certificate revocation", Network and + Distributed Systems Security (NDSS) Symposium 2020 , n.d., + . + + [W3C.SL] Longley, D., Sporny, M., and O. Steele, "W3C Bitstring + Status List v1.0", December 2024, + . + +Appendix A. ASN.1 Module + + The following module adheres to ASN.1 specifications [X.680] and + [X.690]. It defines the OID used for OAuth Status Mechanism Key + Extended Key Usage. + + + + + + + + + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 56] + +Internet-Draft Token Status List (TSL) March 2026 + + + + OauthStatusSigning-EKU + { iso(1) identified-organization(3) dod(6) internet(1) + security(5) mechanisms(5) pkix(7) id-mod(0) + id-mod-oauth-status-signing-eku (TBD) } + + DEFINITIONS IMPLICIT TAGS ::= + BEGIN + + -- OID Arc + + id-kp OBJECT IDENTIFIER ::= + { iso(1) identified-organization(3) dod(6) internet(1) + security(5) mechanisms(5) pkix(7) kp(3) } + + -- OAuth Extended Key Usage + + id-kp-oauthStatusSigning OBJECT IDENTIFIER ::= { id-kp TBD } + + END + + +Appendix B. Size Comparison + + The following tables show a size comparison for a Status List + (compressed byte array as defined in Section 4.1) and a compressed + Byte Array of UUIDs [RFC9562] (as an approximation to the list of IDs + of Referenced Tokens in a Certificate Revocation List). Readers must + be aware that these are not sizes for complete Status List Tokens in + JSON/CBOR nor Certificate Revocation Lists (CRLs), as they don't + contain metadata, certificates, and signatures. + + If no further metadata is provided in Status List Tokens or CRLs, + then the size of Status Lists or arrays of Certificate ids + (represented as UUIDs) will be the main factors deciding on the + overall size of a Status List Token or CRL, respectively. + +Size of Status Lists for varying amount of entries and revocation rates + + +====+=====+=====+======+======+=====+=====+======+=====+======+====+ + |Size|0.01%|0.1% |1% |2% |5% |10% |25% |50% |75% |100%| + +====+=====+=====+======+======+=====+=====+======+=====+======+====+ + |100k|81 B |252 B|1.4 KB|2.3 |4.5 |6.9 |10.2 |12.2 |10.2 |35 B| + | | | | |KB |KB |KB |KB |KB |KB | | + +----+-----+-----+------+------+-----+-----+------+-----+------+----+ + |1M |442 B|2.2 |13.7 |23.0 |43.9 |67.6 |102.2 |122.1|102.4 |144 | + | | |KB |KB |KB |KB |KB |KB |KB |KB |B | + +----+-----+-----+------+------+-----+-----+------+-----+------+----+ + + + +Looker, et al. Expires 21 September 2026 [Page 57] + +Internet-Draft Token Status List (TSL) March 2026 + + + |10M |3.8 |21.1 |135.4 |230.0 |437.0|672.9|1023.4|1.2 |1023.5|1.2 | + | |KB |KB |KB |KB |KB |KB |KB |MB |KB |KB | + +----+-----+-----+------+------+-----+-----+------+-----+------+----+ + |100M|38.3 |213.0|1.3 MB|2.2 |4.3 |6.6 |10.0 |11.9 |10.0 |11.9| + | |KB |KB | |MB |MB |MB |MB |MB |MB |KB | + +----+-----+-----+------+------+-----+-----+------+-----+------+----+ + + Table 1: Status List Size examples for varying amount of entries and + revocation rates + +Size of compressed array of UUIDv4 (128-bit UUIDs) for varying amount of +entries and revocation rates + + This is a simple approximation of a CRL using an array of UUIDs + without any additional metadata (128-bit UUID per revoked entry). + + +====+=====+======+=====+=====+====+=====+=====+=====+=====+=======+ + |Size|0.01%|0.1% |1% |2% |5% |10% |25% |50% |75% | 100% | + +====+=====+======+=====+=====+====+=====+=====+=====+=====+=======+ + |100k|219 B|1.6 KB|15.4 |29.7 |78.1|154.9|392.9|783.1|1.1 | 1.5 | + | | | |KB |KB |KB |KB |KB |KB |MB | MB | + +----+-----+------+-----+-----+----+-----+-----+-----+-----+-------+ + |1M |1.6 |16.4 |157.7|310.4|781 |1.5 |3.8 |7.6 |11.4 | 15.3 | + | |KB |KB |KB |KB |KB |MB |MB |MB |MB | MB | + +----+-----+------+-----+-----+----+-----+-----+-----+-----+-------+ + |10M |15.3 |155.9 |1.5 |3.1 |7.6 |15.2 |38.2 |76.3 |114.4| 152.6 | + | |KB |KB |MB |MB |MB |MB |MB |MB |MB | MB | + +----+-----+------+-----+-----+----+-----+-----+-----+-----+-------+ + |100M|157.6|1.5 MB|15.3 |30.5 |76.3|152.6|381.4|762.9|1.1 | 1.5 | + | |KB | |MB |MB |MB |MB |MB |MB |GB | GB | + +----+-----+------+-----+-----+----+-----+-----+-----+-----+-------+ + + Table 2: Size examples for 128-bit UUIDs for varying amount of + entries and revocation rates + +Appendix C. Test vectors for Status List encoding + + All examples here are given in the form of JSON or CBOR payloads. + The examples are encoded according to Section 4.2 for JSON and + Section 4.3 for CBOR. The CBOR examples are displayed as hex values. + + All values that are not mentioned for the examples below can be + assumed to be 0 (VALID). All examples are initialized with a size of + 2^20 entries. + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 58] + +Internet-Draft Token Status List (TSL) March 2026 + + +C.1. 1-bit Status List + + The following example uses a 1-bit Status List (2 possible values): + + status[0] = 0b1 + status[1993] = 0b1 + status[25460] = 0b1 + status[159495] = 0b1 + status[495669] = 0b1 + status[554353] = 0b1 + status[645645] = 0b1 + status[723232] = 0b1 + status[854545] = 0b1 + status[934534] = 0b1 + status[1000345] = 0b1 + + JSON encoding: + + { + "bits": 1, + "lst": "eNrt3AENwCAMAEGogklACtKQPg9LugC9k_ACvreiogE + AAKkeCQAAAAAAAAAAAAAAAAAAAIBylgQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAXG9IAAAAAAAAAPwsJAAAAAAAAAAAAAAAvhsSAAAAAAAAAAA + A7KpLAAAAAAAAAAAAAAAAAAAAAJsLCQAAAAAAAAAAADjelAAAAAAAAAAAKjDMAQAAA + ACAZC8L2AEb" + } + + CBOR encoding: + + a2646269747301636c737458bd78daeddc010dc0200c0041a88249400ad2903e0f4b + ba00bd93f002beb7a2a2010000a91e09000000000000000000000000000000807296 + 04000000000000000000000000000000000000000000000000000000000000000000 + 000000000000005c6f4800000000000000fc2c240000000000000000000000be1b12 + 000000000000000000ecaa4b000000000000000000000000000000009b0b09000000 + 00000000000038de9400000000000000002a30cc010000000080642f0bd8011b + +C.2. 2-bit Status List + + The following example uses a 2-bit Status List (4 possible values): + + + + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 59] + +Internet-Draft Token Status List (TSL) March 2026 + + + status[0] = 0b01 + status[1993] = 0b10 + status[25460]= 0b01 + status[159495] = 0b11 + status[495669] = 0b01 + status[554353] = 0b01 + status[645645] = 0b10 + status[723232] = 0b01 + status[854545] = 0b01 + status[934534] = 0b10 + status[1000345] = 0b11 + + JSON encoding: + + { + "bits": 2, + "lst": "eNrt2zENACEQAEEuoaBABP5VIO01fCjIHTMStt9ovGV + IAAAAAABAbiEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEB5WwIAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAID0ugQAAAAAAAAAAAAAAAAAQG12SgAAA + AAAAAAAAAAAAAAAAAAAAAAAAOCSIQEAAAAAAAAAAAAAAAAAAAAAAAD8ExIAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwJEuAQAAAAAAAAAAAAAAAAAAAAAAAMB9S + wIAAAAAAAAAAAAAAAAAAACoYUoAAAAAAAAAAAAAAEBqH81gAQw" + } + + CBOR encoding: + + a2646269747302636c737459013d78daeddb310d00211000412ea1a04004fe5520ed + 357c28c81d3312b6df68bc65480000000000406e2101000000000000000000000000 + 0000000000000000000000000000000000000040795b020000000000000000000000 + 00000000000000000000000000000000000000000000000000000000000000000000 + 00000000000000000000000000000000000000000000000000000000000000000000 + 0080f4ba0400000000000000000000000000406d764a000000000000000000000000 + 000000000000000000e0922101000000000000000000000000000000000000fc1312 + 00000000000000000000000000000000000000000000000000000000000000c0912e + 01000000000000000000000000000000000000c07d4b020000000000000000000000 + 00000000a8614a0000000000000000000000406a1fcd60010c + +C.3. 4-bit Status List + + The following example uses a 4-bit Status List (16 possible values): + + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 60] + +Internet-Draft Token Status List (TSL) March 2026 + + + status[0] = 0b0001 + status[1993] = 0b0010 + status[35460] = 0b0011 + status[459495] = 0b0100 + status[595669] = 0b0101 + status[754353] = 0b0110 + status[845645] = 0b0111 + status[923232] = 0b1000 + status[924445] = 0b1001 + status[934534] = 0b1010 + status[1004534] = 0b1011 + status[1000345] = 0b1100 + status[1030203] = 0b1101 + status[1030204] = 0b1110 + status[1030205] = 0b1111 + + JSON encoding: + + { + "bits": 4, + "lst": "eNrt0EENgDAQADAIHwImkIIEJEwCUpCEBBQRHOy35Li + 1EjoOQGabAgAAAAAAAAAAAAAAAAAAACC1SQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABADrsCAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAADoxaEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIIoCgAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACArpwKAAAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAAAAAAAGhqVkAzlwIAAAAAiGVRAAAAAAAAAAAAAAAAAAA + AAAAAAAAAAAAAAAAAAAAAAABx3AoAgLpVAQAAAAAAAAAAAAAAwM89rwMAAAAAAAAAA + AjsA9xMBMA" + } + + CBOR encoding: + + + + + + + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 61] + +Internet-Draft Token Status List (TSL) March 2026 + + + a2646269747304636c737459024878daedd0410d8030100030081f0226908204244c + 025290840414111cecb7e4b8b5123a0e40669b020000000000000000000000000000 + 0020b549010000000000000000000000000000000000000000000000000000000000 + 00000000000000000000000000000000000000000000000000000000000000000000 + 00000000000000000000000000000000000000000000000000000000000000000000 + 00000000000000000000000000000000000000000000000000000000000000000000 + 00000000000000000000000000000000000000000000000000000000000000000000 + 00000000000000000000000000000000000000000000000000000000000000000000 + 0000000000400ebb0200000000000000000000000000000000000000000000000000 + 00000000000000000000000000000000000000000000000000000000000000000000 + 000000000000e8c5a100000000000000000000000000000000000000000000000000 + 00000000000000000000000000000000000000000000000000000000000000000000 + 00000000000000000000000000000000000082280a00000000000000000000000000 + 00000000000000000000000000000000000000000000000000000000000080ae9c0a + 00000000000000000000000000000000000000000000000000000000000000000000 + 000000686a5640339702000000008865510000000000000000000000000000000000 + 00000000000000000000000000000071dc0a0080ba55010000000000000000000000 + c0cf3daf03000000000000000008ec03dc4c04c0 + +C.4. 8-bit Status List + + The following example uses an 8-bit Status List (256 possible + values): + + status[233478] = 0b00000000 + status[52451] = 0b00000001 + status[576778] = 0b00000010 + status[513575] = 0b00000011 + status[468106] = 0b00000100 + status[292632] = 0b00000101 + status[214947] = 0b00000110 + status[182323] = 0b00000111 + status[884834] = 0b00001000 + status[66653] = 0b00001001 + status[62489] = 0b00001010 + status[196493] = 0b00001011 + status[458517] = 0b00001100 + status[487925] = 0b00001101 + status[55649] = 0b00001110 + status[416992] = 0b00001111 + status[879796] = 0b00010000 + status[462297] = 0b00010001 + status[942059] = 0b00010010 + status[583408] = 0b00010011 + status[13628] = 0b00010100 + status[334829] = 0b00010101 + status[886286] = 0b00010110 + status[713557] = 0b00010111 + + + +Looker, et al. Expires 21 September 2026 [Page 62] + +Internet-Draft Token Status List (TSL) March 2026 + + + status[582738] = 0b00011000 + status[326064] = 0b00011001 + status[451545] = 0b00011010 + status[705889] = 0b00011011 + status[214350] = 0b00011100 + status[194502] = 0b00011101 + status[796765] = 0b00011110 + status[202828] = 0b00011111 + status[752834] = 0b00100000 + status[721327] = 0b00100001 + status[554740] = 0b00100010 + status[91122] = 0b00100011 + status[963483] = 0b00100100 + status[261779] = 0b00100101 + status[793844] = 0b00100110 + status[165255] = 0b00100111 + status[614839] = 0b00101000 + status[758403] = 0b00101001 + status[403258] = 0b00101010 + status[145867] = 0b00101011 + status[96100] = 0b00101100 + status[477937] = 0b00101101 + status[606890] = 0b00101110 + status[167335] = 0b00101111 + status[488197] = 0b00110000 + status[211815] = 0b00110001 + status[797182] = 0b00110010 + status[582952] = 0b00110011 + status[950870] = 0b00110100 + status[765108] = 0b00110101 + status[341110] = 0b00110110 + status[776325] = 0b00110111 + status[745056] = 0b00111000 + status[439368] = 0b00111001 + status[559893] = 0b00111010 + status[149741] = 0b00111011 + status[358903] = 0b00111100 + status[513405] = 0b00111101 + status[342679] = 0b00111110 + status[969429] = 0b00111111 + status[795775] = 0b01000000 + status[566121] = 0b01000001 + status[460566] = 0b01000010 + status[680070] = 0b01000011 + status[117310] = 0b01000100 + status[480348] = 0b01000101 + status[67319] = 0b01000110 + status[661552] = 0b01000111 + + + +Looker, et al. Expires 21 September 2026 [Page 63] + +Internet-Draft Token Status List (TSL) March 2026 + + + status[841303] = 0b01001000 + status[561493] = 0b01001001 + status[138807] = 0b01001010 + status[442463] = 0b01001011 + status[659927] = 0b01001100 + status[445910] = 0b01001101 + status[1046963] = 0b01001110 + status[829700] = 0b01001111 + status[962282] = 0b01010000 + status[299623] = 0b01010001 + status[555493] = 0b01010010 + status[292826] = 0b01010011 + status[517215] = 0b01010100 + status[551009] = 0b01010101 + status[898490] = 0b01010110 + status[837603] = 0b01010111 + status[759161] = 0b01011000 + status[459948] = 0b01011001 + status[290102] = 0b01011010 + status[1034977] = 0b01011011 + status[190650] = 0b01011100 + status[98810] = 0b01011101 + status[229950] = 0b01011110 + status[320531] = 0b01011111 + status[335506] = 0b01100000 + status[885333] = 0b01100001 + status[133227] = 0b01100010 + status[806915] = 0b01100011 + status[800313] = 0b01100100 + status[981571] = 0b01100101 + status[527253] = 0b01100110 + status[24077] = 0b01100111 + status[240232] = 0b01101000 + status[559572] = 0b01101001 + status[713399] = 0b01101010 + status[233941] = 0b01101011 + status[615514] = 0b01101100 + status[911768] = 0b01101101 + status[331680] = 0b01101110 + status[951527] = 0b01101111 + status[6805] = 0b01110000 + status[552366] = 0b01110001 + status[374660] = 0b01110010 + status[223159] = 0b01110011 + status[625884] = 0b01110100 + status[417146] = 0b01110101 + status[320527] = 0b01110110 + status[784154] = 0b01110111 + + + +Looker, et al. Expires 21 September 2026 [Page 64] + +Internet-Draft Token Status List (TSL) March 2026 + + + status[338792] = 0b01111000 + status[1199] = 0b01111001 + status[679804] = 0b01111010 + status[1024680] = 0b01111011 + status[40845] = 0b01111100 + status[234603] = 0b01111101 + status[761225] = 0b01111110 + status[644903] = 0b01111111 + status[502167] = 0b10000000 + status[121477] = 0b10000001 + status[505144] = 0b10000010 + status[165165] = 0b10000011 + status[179628] = 0b10000100 + status[1019195] = 0b10000101 + status[145149] = 0b10000110 + status[263738] = 0b10000111 + status[269256] = 0b10001000 + status[996739] = 0b10001001 + status[346296] = 0b10001010 + status[555864] = 0b10001011 + status[887384] = 0b10001100 + status[444173] = 0b10001101 + status[421844] = 0b10001110 + status[653716] = 0b10001111 + status[836747] = 0b10010000 + status[783119] = 0b10010001 + status[918762] = 0b10010010 + status[946835] = 0b10010011 + status[253764] = 0b10010100 + status[519895] = 0b10010101 + status[471224] = 0b10010110 + status[134272] = 0b10010111 + status[709016] = 0b10011000 + status[44112] = 0b10011001 + status[482585] = 0b10011010 + status[461829] = 0b10011011 + status[15080] = 0b10011100 + status[148883] = 0b10011101 + status[123467] = 0b10011110 + status[480125] = 0b10011111 + status[141348] = 0b10100000 + status[65877] = 0b10100001 + status[692958] = 0b10100010 + status[148598] = 0b10100011 + status[499131] = 0b10100100 + status[584009] = 0b10100101 + status[1017987] = 0b10100110 + status[449287] = 0b10100111 + + + +Looker, et al. Expires 21 September 2026 [Page 65] + +Internet-Draft Token Status List (TSL) March 2026 + + + status[277478] = 0b10101000 + status[991262] = 0b10101001 + status[509602] = 0b10101010 + status[991896] = 0b10101011 + status[853666] = 0b10101100 + status[399318] = 0b10101101 + status[197815] = 0b10101110 + status[203278] = 0b10101111 + status[903979] = 0b10110000 + status[743015] = 0b10110001 + status[888308] = 0b10110010 + status[862143] = 0b10110011 + status[979421] = 0b10110100 + status[113605] = 0b10110101 + status[206397] = 0b10110110 + status[127113] = 0b10110111 + status[844358] = 0b10111000 + status[711569] = 0b10111001 + status[229153] = 0b10111010 + status[521470] = 0b10111011 + status[401793] = 0b10111100 + status[398896] = 0b10111101 + status[940810] = 0b10111110 + status[293983] = 0b10111111 + status[884749] = 0b11000000 + status[384802] = 0b11000001 + status[584151] = 0b11000010 + status[970201] = 0b11000011 + status[523882] = 0b11000100 + status[158093] = 0b11000101 + status[929312] = 0b11000110 + status[205329] = 0b11000111 + status[106091] = 0b11001000 + status[30949] = 0b11001001 + status[195586] = 0b11001010 + status[495723] = 0b11001011 + status[348779] = 0b11001100 + status[852312] = 0b11001101 + status[1018463] = 0b11001110 + status[1009481] = 0b11001111 + status[448260] = 0b11010000 + status[841042] = 0b11010001 + status[122967] = 0b11010010 + status[345269] = 0b11010011 + status[794764] = 0b11010100 + status[4520] = 0b11010101 + status[818773] = 0b11010110 + status[556171] = 0b11010111 + + + +Looker, et al. Expires 21 September 2026 [Page 66] + +Internet-Draft Token Status List (TSL) March 2026 + + + status[954221] = 0b11011000 + status[598210] = 0b11011001 + status[887110] = 0b11011010 + status[1020623] = 0b11011011 + status[324632] = 0b11011100 + status[398244] = 0b11011101 + status[622241] = 0b11011110 + status[456551] = 0b11011111 + status[122648] = 0b11100000 + status[127837] = 0b11100001 + status[657676] = 0b11100010 + status[119884] = 0b11100011 + status[105156] = 0b11100100 + status[999897] = 0b11100101 + status[330160] = 0b11100110 + status[119285] = 0b11100111 + status[168005] = 0b11101000 + status[389703] = 0b11101001 + status[143699] = 0b11101010 + status[142524] = 0b11101011 + status[493258] = 0b11101100 + status[846778] = 0b11101101 + status[251420] = 0b11101110 + status[516351] = 0b11101111 + status[83344] = 0b11110000 + status[171931] = 0b11110001 + status[879178] = 0b11110010 + status[663475] = 0b11110011 + status[546865] = 0b11110100 + status[428362] = 0b11110101 + status[658891] = 0b11110110 + status[500560] = 0b11110111 + status[557034] = 0b11111000 + status[830023] = 0b11111001 + status[274471] = 0b11111010 + status[629139] = 0b11111011 + status[958869] = 0b11111100 + status[663071] = 0b11111101 + status[152133] = 0b11111110 + status[19535] = 0b11111111 + + JSON encoding: + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 67] + +Internet-Draft Token Status List (TSL) March 2026 + + + { + "bits": 8, + "lst": "eNrt0WOQM2kYhtGsbdu2bdu2bdu2bdu2bdu2jVnU1my + -SWYm6U5enFPVf7ue97orFYAo7CQBAACQuuckAABStqUEAAAAAAAAtN6wEgAE71QJA + AAAAIrwhwQAAAAAAdtAAgAAAAAAACLwkAQAAAAAAAAAAACUaFcJAACAeJwkAQAAAAA + AAABQvL4kAAAAWmJwCQAAAAAAAAjAwBIAAAB06ywJoDKQBARpfgkAAAAAAAAAAAAAA + AAAAACo50sJAAAAAAAAAOiRcSQAAAAAgAJNKgEAAG23mgQAAAAAAECw3pUAQvegBAA + AAAAAAADduE4CAAAAyjSvBAAQiw8koHjvSABAb-wlARCONyVoxtMSZOd0CQAAAOjWD + RKQmLckAAAAAACysLYEQGcnSAAAAAAQooUlAABI15kSAIH5RAIgLB9LABC4_SUgGZN + IAABAmM6RoLbTJIASzCIBAEAhfpcAAAAAAABquk8CAAAAAAAAaJl9SvvzBOICAFWmk + IBgfSgBAAAANOgrCQAAAAAAAADStK8EAAC03gASAAAAAAAAAADFWFUCAAAAMjOaBEA + DHpYAQjCIBADduFwCAAAAAGitMSSI3BUSAECOHpAA6IHrJQAAAAAAsjeVBAAAKRpVA + orWvwQAAAAAAAAAkKRtJAAAAAAAgCbcLAF0bXUJAAAAoF02kYDg7CYBAAAAAEB6NpQ + AAAAAAAAAAAAAAEr1uQQAAF06VgIAAAAAAAAAqDaeBAAQqgMkAAAAAABogQMlAAAAA + AAa87MEAAAQiwslAAAAAAAAAAAAAAAAMrOyBAAAiekv-hcsY0Sgne6QAAAAAAAgaUt + JAAAAAAAAAAAAAAAAAAAAAAAAAADwt-07vjVkAAAAgDy8KgFAUEaSAAAAAJL3vgQAW + dhcAgAAoBHDSUDo1pQAAACI2o4SAABZm14CALoyuwQAAPznGQkgZwdLAAAQukclAAA + AAAAAAAAAgKbMKgEAAAAAAAAAAAAAAAAAAECftpYAAAAAAAAAAAAACnaXBAAAAADk7 + iMJAAAAAAAAAABqe00CAnGbBBG4TAIAgFDdKgFAXCaWAAAAAAAAAAAAAAAAAKAJQwR + 72XbGAQAAAKAhh0sAAAAAAABQgO8kAAAAAAAAAAAAACAaM0kAAAC5W0QCAIJ3mAQAx + GwxCQAA6nhSAsjZBRIAANEbWQIAAAAAaJE3JACAwA0qAUBIVpKAlphbAiAPp0iQnKE + kAAAAAAAgBP1KAAAAdOl4CQAAAAAAAPjLZBIAAG10RtrPm8_CAEBMTpYAAAAAAIjQY + BL8z5QSAAAAAEDYPpUAACAsj0gAAADQkHMlAAjHDxIA0Lg9JQAAgHDsLQEAAABAQS6 + WAAAAgLjNFs2l_RgLAIAEfCEBlGZZCQAAaIHjJACgtlskAAAozb0SAAAAVFtfAgAAA + AAAAAAAAAAAAAAAAAAAAKDDtxIAAAAAVZaTAKB5W0kAANCAsSUgJ0tL0GqHSNBbL0g + AZflRAgCARG0kQXNmlgCABiwkAQAAAEB25pIAAAAAAAAAAAAAoFh9SwAAAAAAADWNm + OSrpjFsEoaRgDKcF9Q1dxsEAAAAAAAAAAAAAAAAgPZ6SQIAAAAAAAAAgChMLgEAAAA + AAAAAqZlQAsK2qQQAAAAAAAD06XUJAAAAqG9bCQAAgLD9IgEAAAAAAAAAAAAAAAAAA + EBNe0gAAAAAAAAAAEBPHSEBAAAAlOZtCYA4fS8B0GFRCQAo0gISAOTgNwmC840EAAA + AAAAAAAAAAAAAAAAAUJydJfjXPBIAAAAAAAAAAAAAAABk6WwJAAAAAAAAAAAAAAAAq + G8UCQAAgPpOlAAAIA83SQAANWwc9HUjGAgAAAAAAACAusaSAAAAAAAAAAAAAAAAAAA + AAAAAAAAAqHKVBACQjxklAAAAAAAAAKBHxpQAAAAAACBME0lAdlaUAACyt7sEAAAA0 + Nl0EgAAAAAAAAAAAABA-8wgAQAAAAAAAKU4SgKgUtlBAgAAAAAAAAAAgMCMLwEE51k + JICdzSgCJGl2CsE0tAQAA0L11JQAAAAAAAAjUOhIAAAAAAAAAAAAAAGTqeQkAAAAAA + AAAAAAAKM8SEjTrJwkAAAAAAACocqQEULgVJAAAACjDUxJUKgtKAAAAqbpRAgCA0n0 + mAQAAAABAGzwmAUCTLpUAAAAAAAAAAEjZNRIAAAAAAAAAAAAAAAAAAAAA8I-vJaAlh + pQAAAAAAHrvzjJ-OqCuuVlLAojP8BJAr70sQZVDJYAgXS0BAAAAAAAAAAAAtMnyEgA + AAAAAFONKCQAAAAAAAADorc0kAAAAAAAAgDqOlgAAAAAAAAAAAADIwv0SAAAAAAAAA + AAAAADBuV0CIFVDSwAAAABAAI6RAAAAAGIwrQSEZAsJAABouRclAAAAAKDDrxIAAAA + 0bkkJgFiMKwEAAAAAAHQyhwRk7h4JAAAAAAAAAAAgatdKAACUYj0JAAAAAAAAAAAAQ + nORBLTFJRIAAAAAkIaDJAAAAJryngQAAAAAAAAAAAA98oQEAAAAAAAAAEC2zpcgWY9 + LQKL2kwAgGK9IAAAAAPHaRQIAAAAAAAAAAADIxyoSAAAAAAAAAAAAAADQFotLAECz_ + gQ1PX-B" + } + + CBOR encoding: + + + + + +Looker, et al. Expires 21 September 2026 [Page 68] + +Internet-Draft Token Status List (TSL) March 2026 + + + a2646269747308636c73745907b078daedd1639033691886d1ac6ddbb66ddbb66ddb + b66ddbb66ddbb68d59d4d66cbe496626e94e5e9c53d57fbb9ef7ba2b158028ec2401 + 000090bae724000052b6a504000000000000b4deb0120004ef5409000000008af087 + 040000000001db400200000000000022f09004000000000000000000946857090000 + 80789c24010000000000000050bcbe240000005a62700900000000000008c0c01200 + 000074eb2c09a032900404697e09000000000000000000000000000000a8e74b0900 + 000000000000e89171240000000080024d2a0100006db79a04000000000040b0de95 + 0042f7a00400000000000000ddb84e02000000ca34af0400108b0f24a078ef480040 + 6fec2501108e372568c6d31264e77409000000e8d60d129098b7240000000000b2b0 + b604406727480000000010a28525000048d799120081f94402202c1f4b0010b8fd25 + 2019934800004098ce91a0b6d3248012cc22010040217e970000000000006aba4f02 + 00000000000068997d4afbf304e2020055a69080607d280100000034e82b09000000 + 00000000d2b4af040000b4de00120000000000000000c558550200000032339a0440 + 031e96004230880400ddb85c020000000068ad312488dc151200408e1e9000e881eb + 250000000000b23795040000291a55028ad6bf040000000000000090a46d24000000 + 00008026dc2c01746d7509000000a05d369180e0ec260100000000407a3694000000 + 00000000000000004af5b90400005d3a560200000000000000a8369e040010aa0324 + 00000000006881032500000000001af3b3040000108b0b2500000000000000000000 + 000032b3b204000089e92ffa172c6344a09dee90000000000020694b490000000000 + 000000000000000000000000000000f0b7ed3bbe3564000000803cbc2a0140504692 + 0000000092f7be040059d85c020000a011c34940e8d69400000088da8e120000599b + 5e0200ba32bb040000fce719092067074b000010ba472500000000000000000080a6 + cc2a010000000000000000000000000000409fb696000000000000000000000a7697 + 0400000000e4ee230900000000000000006a7b4d0202719b0411b84c02008050dd2a + 01405c269600000000000000000000000000a00943047bd976c601000000a021874b + 0000000000005080ef2400000000000000000000201a3349000000b95b4402008277 + 980400c46c31090000ea785202c8d905120000d11b590200000000689137240080c0 + 0d2a01404856928096985b02200fa748909ca12400000000002004fd4a00000074e9 + 7809000000000000f8cb641200006d7446dacf9bcfc200404c4e96000000000088d0 + 6012fccf94120000000040d83e950000202c8f48000000d09073250008c70f1200d0 + b83d2500008070ec2d0100000040412e9600000080b8cd16cda5fd180b0080047c21 + 019466590900006881e32400a0b65b24000028cdbd12000000545b5f020000000000 + 00000000000000000000000000a0c3b7120000000055969300a0795b490000d080b1 + 2520274b4bd06a8748d05b2f480065f951020080446d24417366960080062c240100 + 00004076e69200000000000000000000a0587d4b000000000000358d98e4aba6316c + 12869180329c17d435771b0400000000000000000000000080f67a49020000000000 + 000080284c2e0100000000000000a9995002c2b6a904000000000000f4e975090000 + 00a86f5b09000080b0fd22010000000000000000000000000000404d7b4800000000 + 00000000404f1d210100000094e66d0980387d2f01d06151090028d2021200e4e037 + 0982f38d04000000000000000000000000000000509c9d25f8d73c12000000000000 + 00000000000064e96c09000000000000000000000000a86f1409000080fa4e940000 + 200f37490000356c1cf47523180800000000000080bac69200000000000000000000 + 0000000000000000000000a872950400908f192500000000000000a047c694000000 + 0000204c1349407656940000b2b7bb04000000d0d974120000000000000000000040 + fbcc2001000000000000a5384a02a052d94102000000000000000080c08c2f0104e7 + 59092027734a00891a5d82b04d2d010000d0bd752500000000000008d43a12000000 + 000000000000000064ea79090000000000000000000028cf121234eb270900000000 + 0000a872a40450b8152400000028c35312542a0b4a000000a9ba51020080d27d2601 + + + +Looker, et al. Expires 21 September 2026 [Page 69] + +Internet-Draft Token Status List (TSL) March 2026 + + + 00000000401b3c260140932e95000000000000000048d93512000000000000000000 + 00000000000000f08faf25a025869400000000007aefce327e3aa0aeb9594b0288cf + f01240afbd2c4195432580205d2d01000000000000000000b4c9f212000000000014 + e34a0900000000000000e8adcd24000000000000803a8e9600000000000000000000 + c8c2fd120000000000000000000000c1b95d022055434b0000000040008e91000000 + 006230ad0484640b09000068b9172500000000a0c3af12000000346e490980588c2b + 0100000000007432870464ee1e090000000000000000206ad74a000094623d090000 + 0000000000000042739104b4c5251200000000908683240000009af29e0400000000 + 00000000003df284040000000000000040b6ce9720598f4b40a2f693002018af4800 + 000000f1da4502000000000000000000c8c72a120000000000000000000000d0168b + 4b0040b3fe04353d7f81 + +Document History + + [[ To be removed from the final specification ]] + + -19 + + * revert grapahics to ASCII + + * grammar, spelling, nits + + * add official link to ISO 18013-5 specification + + -18 + + * add references to SD-JWT VC and SD-CWT + + -17 + + * change SD-JWT VC reference to SD-JWT + + * clarify that Status List validation MUST not be performed if + Referenced Token validation is deemed invalid already + + -16 + + * change http status codes & query parameter wording for the + historical resolution + + * grammatical/style fixes + + * making several SHOULDs non-normative + + * small corrections in the introduction + + * change guidance around HTTP content negotiation to refer to RFC + 9110 + + + +Looker, et al. Expires 21 September 2026 [Page 70] + +Internet-Draft Token Status List (TSL) March 2026 + + + * strengthen normative guidance around handling cases or redirection + + * changing media type contact to oauth WG mailing list + + * update discussion around collusion risk in unlinkability section + + * strength guidance to MUST about rejecting reference tokens with an + index which is out of bounds of the resolved list + + * remove non-normative ISO mdoc examples + + -15 + + * limit Status List Token CWT COSE message to Sign1/Mac0 + + * be explicit about tagging and re-add cose_sign1 tag to example + + * add description field to EKU iana registration request + + * fix typos in referenced token + + * fix typos + + * make IANA references informative + + * remove unused iana.jose reference + + -14 + + * use binary value encoding for all test vectors (display purposes + only) + + * removed bytes from graphic that were intepreted as padding bytes + + * removed 0x0B from application-specific Status Type + + * reemphasized that expired tokens with status "VALID" are still + expired + + * renamed section "Status List Aggregation in JSON Format" to + "Status List Aggregation Data Structure" + + * slightly restructure/clarify referenced token cose section + + * Add ASN.1 module + + * many nits and improvements from genart review + + + + +Looker, et al. Expires 21 September 2026 [Page 71] + +Internet-Draft Token Status List (TSL) March 2026 + + + * remove cose_sign1 tag from statuslist in cwt form examples + + * slightly restructure/clarify referenced token cose section + + * Add ASN.1 module + + * removed DL suspension example + + -13 + + * add definition of client to terminology + + * Make exp and ttl recommended in claim description (fixes + inconsistency, was recommended in other text) + + * Add short security consideraiton on redirects and ttl + + * fix CORS spec to specific version + + * explain KYC + + * link implementation guidance to exp and ttl in Status List Token + definition + + * reference RFC7515 instead of IANA:JOSE + + * add a note that cwt is encoded in raw/binary. + + * added further privacy consideration around issuer tracking using + unique URIs + + -12 + + * Allow for extended key usage OID to be used for other status + mechanisms + + * add Paul's affiliation + + * add feedback from Dan Moore + + * change JSON Status List structure to only contain JSON object + + * further nitpicks + + * clarifying status and status_list IANA descriptions for JWT/CWT + + * clarifying description texts for status and status_list in CBOR + + + + +Looker, et al. Expires 21 September 2026 [Page 72] + +Internet-Draft Token Status List (TSL) March 2026 + + + * splitting Linkability Mitigation from Token Lifecycle section in + Implementation Consideration + + * relax the accept header from must to should + + -11 + + * incorporate feedback from shepherd review + + * some nitpicks + + * even more nitpicks + + -10 + + * improve caching guidelines and move them to implementaiton + considerations + + * Add CoAP Content-Format ID and IANA registration + + * Add size comparison for status list and compressed uuids + + * Change Controller IESG for OAuths Parameters Registration + + -09 + + * update acknowledgments + + * introduce dedicated section for compressed byte array of the + Status List + + * fix Status List definitions + + * Add CDDL for CBOR StatusList encoding + + * add diagram for Status List Aggregation for further explanation + + * rename "chunking" of Status List Tokens (for scalability reasons) + into "divide .. up" + + -08 + + * Fix cwt typ value to full media type + + * Holders may also fetch and verify Status List Tokens + + * Update terminology for referenced token and Status List Token + + + + +Looker, et al. Expires 21 September 2026 [Page 73] + +Internet-Draft Token Status List (TSL) March 2026 + + + -07 + + * add considerations about External Status Issuer or Status Provider + + * add recommendations for Key Resolution and Trust Management + + * add extended key usage extensions for x509 + + * Relying Parties avoiding correlatable Information + + * editorial changes on terminology and Referenced Tokens + + * clarify privacy consideration around one time use referenced + tokens + + * explain the Status List Token size dependencies + + * explain possibility to chunk Status List Tokens depending on + Referenced Token's expiry date + + * add short-lived tokens in the Rationale + + * rename Status Mechanism Methods registry to Status Mechanisms + registry + + * changes as requested by IANA review + + * emphasize that security and privacy considerations only apply to + Status List and no other status mechanisms + + * differentiate unlinkability between Issuer-RP and RP-RP + + * add more test vectors for the status list encoding + + * add prior art + + * updated language around application specific status type values + and assigned ranges for application specific usage + + * add short security considerations section for mac based + deployments + + * privacy considerations for other status types like suspended + + * fix aggregation_uri text in referenced token + + * mention key resolution in validation rules + + + + +Looker, et al. Expires 21 September 2026 [Page 74] + +Internet-Draft Token Status List (TSL) March 2026 + + + -06 + + * iana registration text updated with update procedures + + * explicitly mention that status list is expected to be contained in + cryptographically secured containers + + * reworked and simplified introduction and abstract + + * specify http status codes and allow redirects + + * add status_list_aggregation_endpoint OAuth metadata + + * remove unsigned options (json/cbor) of status list + + * add section about mixing status list formats and media type + + * fixes from IETF review + + * update guidance around ttl + + * add guidance around aggregation endpoint + + -05 + + * add optional support for historical requests + + * update CBOR claim definitions + + * improve section on Status Types and introduce IANA registry for it + + * add Status Issuer and Status Provider role description to the + introduction/terminology + + * add information on third party hosting to security consideration + + * remove constraint that Status List Token must not use a MAC + + -04 + + * add mDL example as Referenced Token and consolidate CWT and CBOR + sections + + * add implementation consideration for Default Values, Double + Allocation and Status List Size + + * add privacy consideration on using private relay protocols + + + + +Looker, et al. Expires 21 September 2026 [Page 75] + +Internet-Draft Token Status List (TSL) March 2026 + + + * add privacy consideration on observability of outsiders + + * add security considerations on correct parsing and decoding + + * remove requirement for matching iss claim in Referenced Token and + Status List Token + + * add sd-jwt-vc example + + * fix CWT status_list map encoding + + * editorial fixes + + * add CORS considerations to the http endpoint + + * fix reference of Status List in CBOR format + + * added status_list CWT claim key assigned + + * move base64url definition to terminology + + -03 + + * remove unused reference to RFC9111 + + * add validation rules for status list token + + * introduce the status list aggregation mechanism + + * relax requirements for status_list claims to contain other + parameters + + * change cwt referenced token example to hex and annotated hex + + * require TLS only for fetching Status List, not for Status List + Token + + * remove the undefined phrase Status List endpoint + + * remove http caching in favor of the new ttl claim + + * clarify the sub claim of Status List Token + + * relax status_list iss requirements for CWT + + * Fixes missing parts & iana ttl registration in CWT examples + + -02 + + + +Looker, et al. Expires 21 September 2026 [Page 76] + +Internet-Draft Token Status List (TSL) March 2026 + + + * add ttl claim to Status List Token to convey caching + + * relax requirements on referenced token + + * clarify Deflate / zlib compression + + * make a reference to the Issuer-Holder-Verifier model of SD-JWT VC + + * add COSE/CWT/CBOR encoding + + -01 + + * Rename title of the draft + + * add design consideration to the introduction + + * Change status claim to in referenced token to allow re-use for + other mechanisms + + * Add IANA Registry for status mechanisms + + * restructure the sections of this document + + * add option to return an unsigned Status List + + * Changing compression from gzip to zlib + + * Change typo in Status List Token sub claim description + + * Add access token as an example use-case + + -00 + + * Initial draft after working group adoption + + * update acknowledgments + + * renamed Verifier to Relying Party + + * added IANA consideration + + [ draft-ietf-oauth-status-list ] + + -01 + + * Applied editorial improvements suggested by Michael Jones. + + -00 + + + +Looker, et al. Expires 21 September 2026 [Page 77] + +Internet-Draft Token Status List (TSL) March 2026 + + + * Initial draft + +Authors' Addresses + + Tobias Looker + MATTR + Email: tobias.looker@mattr.global + + + Paul Bastian + Bundesdruckerei + Email: paul.bastian@posteo.de + + + Christian Bormann + SPRIND + Email: chris.bormann@gmx.de + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +Looker, et al. Expires 21 September 2026 [Page 78] diff --git a/docs/specs/references/token-status-list.md b/docs/specs/references/token-status-list.md new file mode 100644 index 0000000..2fbb6e4 --- /dev/null +++ b/docs/specs/references/token-status-list.md @@ -0,0 +1,161 @@ +# Token Status List (TSL) — draft-ietf-oauth-status-list-19 + +> **Source:** +> **Authors:** T. Looker (MATTR), P. Bastian (Bundesdruckerei), C. Bormann (SPRIND) +> **Status:** Standards Track Internet-Draft (expires 21 September 2026) +> **Full text:** `token-status-list-draft-19.txt` (this directory) + +## Abstract + +Defines a status mechanism called **Token Status List (TSL)**, with data +structures and processing rules for representing the status of tokens secured +by JOSE or COSE — including JWT, SD-JWT, SD-JWT-VC, CWT, and ISO mdoc. + +## Key Concepts + +### Architecture + +``` ++----------------+ describes status +------------------+ +| Status List |------------------->| Referenced Token | +| (JSON or CBOR) |<-------------------| (JOSE, COSE, ..) | ++-------+--------+ references +------------------+ + | + | embedded in + v ++-------------------+ +| Status List Token | +| (JWT or CWT) | ++-------------------+ +``` + +### Roles + +| Role | Description | +|------|-------------| +| **Issuer** | Issues Referenced Tokens to Holder | +| **Status Issuer** | Issues Status List Tokens (may be same as Issuer) | +| **Status Provider** | Hosts Status List Tokens on accessible endpoint | +| **Holder** | Receives and presents Referenced Tokens | +| **Relying Party** | Validates Referenced Tokens by fetching Status List | + +### Status List (§4) + +A compressed byte array where each Referenced Token is allocated an index +during issuance. The value at that index encodes the token's status. + +- **bits**: 1, 2, 4, or 8 bits per token (supporting 2–256 status values) +- **lst**: base64url-encoded DEFLATE+ZLIB compressed byte array +- Scales to millions of tokens while remaining small (herd privacy) + +### Status List Token (§5) + +#### JWT Format + +```json +{ + "alg": "ES256", + "kid": "12", + "typ": "statuslist+jwt" +} +. +{ + "exp": 2291720170, + "iat": 1686920170, + "status_list": { + "bits": 1, + "lst": "eNrbuRgAAhcBXQ" + }, + "sub": "https://example.com/statuslists/1", + "ttl": 43200 +} +``` + +Required claims: `sub` (URI of this status list), `iat`, `status_list`. +Recommended: `exp`, `ttl` (cache lifetime in seconds). + +### Referenced Token (§6) + +A Referenced Token includes a `status` claim pointing to its position +in a Status List: + +```json +{ + "status": { + "status_list": { + "idx": 0, + "uri": "https://example.com/statuslists/1" + } + } +} +``` + +For **SD-JWT-VC**, the `status` claim is part of the JWT payload: + +```json +{ + "vct": "https://example.com/credential/type", + "iss": "https://issuer.example.com", + "status": { + "status_list": { + "idx": 0, + "uri": "https://issuer.example.com/statuslists/1" + } + } +} +``` + +### Status Types (§7) + +| Value | Name | Description | +|-------|------|-------------| +| 0x00 | VALID | Token is valid (default) | +| 0x01 | INVALID | Token is revoked/invalid | +| 0x02 | SUSPENDED | Token is temporarily suspended | +| 0x03 | APPLICATION_SPECIFIC | Application-defined meaning | + +### Verification (§8) + +1. Fetch Status List Token from the `uri` in the Referenced Token's `status` claim +2. Validate the Status List Token (signature, expiry, etc.) +3. Verify `sub` of Status List Token matches `uri` in Referenced Token +4. Extract the status value at position `idx` from the decompressed byte array +5. Interpret the status value per the Status Types registry + +### Security Considerations (§11) + +- Status List Token MUST be cryptographically signed +- Key resolution and trust chain validation required +- Careful handling of HTTP redirects (3xx) +- Expiration and caching policies to balance freshness vs. privacy + +### Privacy Considerations (§12) + +- **Herd privacy**: Large lists prevent correlation of individual tokens +- **Issuer tracking**: Status Provider may observe which tokens are checked +- **Unlinkability**: Multiple verifiers checking same list cannot correlate holders +- **External Status Provider**: Decouples issuer from status checks + +## Relevance to Harbour + +The `CRSetEntry` type in harbour-core-credential.yaml models the +`credentialStatus` claim for harbour credentials. It should align with +the TSL `status` claim structure: + +```json +{ + "credentialStatus": { + "type": "TokenStatusList", + "statusListCredential": "https://issuer.example.com/statuslists/1", + "statusListIndex": 0 + } +} +``` + +The SD-JWT-VC profile (draft-ietf-oauth-sd-jwt-vc) uses the TSL `status` +claim directly in the JWT payload, without the W3C VCDM `credentialStatus` +wrapper. + +## Download Date + +- **2026-03-20** (draft-19) diff --git a/examples/README.md b/examples/README.md index e73cf8f..d18b8ae 100644 --- a/examples/README.md +++ b/examples/README.md @@ -198,12 +198,17 @@ organizational affiliation without the credential itself leaking PII. ### Code ```python -# Python — convert to SD-JWT-VC flat claims -from credentials.claim_mapping import vc_to_sd_jwt_claims, MAPPINGS -mapping = MAPPINGS["harbour.gx:NaturalPersonCredential"] -claims, disclosable = vc_to_sd_jwt_claims(credential, mapping) -# claims: {"iss": ..., "vct": ..., "givenName": "Alice", "memberOf": "did:ethr:0x14a34:0x..."} -# disclosable: ["givenName", "familyName", "email", "memberOf"] +# Python — issue SD-JWT-VC with structured selective disclosure (RFC 9901 §6.2) +from harbour.sd_jwt import issue_sd_jwt_vc + +sd_jwt = issue_sd_jwt_vc( + credential, + private_key, + vct="https://w3id.org/reachhaven/harbour/gx/v1/NaturalPersonCredential", + disclosable=["credentialSubject.givenName", "credentialSubject.familyName", + "credentialSubject.email", "credentialSubject.memberOf"], +) +# Nested structure preserved — sensitive values hidden behind _sd digests ``` --- diff --git a/examples/did-ethr/harbour-signing-service.did.json b/examples/did-ethr/harbour-signing-service.did.json index 34df4f9..036f554 100644 --- a/examples/did-ethr/harbour-signing-service.did.json +++ b/examples/did-ethr/harbour-signing-service.did.json @@ -49,7 +49,7 @@ "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#service-1", "type": "harbour:TrustAnchorService", "didcore:serviceEndpoint": { - "type": "sdo:Organization", + "type": "schema:Organization", "name": "Haven Trust Anchor", "url": "https://resolver.harbour.id/trust-anchors/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" } diff --git a/examples/gaiax_external/Gaia-X_Loire Participant Credential VP_(2026).json b/examples/gaiax_external/Gaia-X_Loire Participant Credential VP_(2026).json index 9861ce8..c36e20e 100644 --- a/examples/gaiax_external/Gaia-X_Loire Participant Credential VP_(2026).json +++ b/examples/gaiax_external/Gaia-X_Loire Participant Credential VP_(2026).json @@ -11,7 +11,7 @@ "https://w3id.org/gaia-x/development#", { "vcard": "http://www.w3.org/2006/vcard/ns#", - "schema": "http://schema.org/" + "schema": "https://schema.org/" } ], "type": [ diff --git a/linkml/harbour-core-credential.yaml b/linkml/harbour-core-credential.yaml index 42e0697..390670d 100644 --- a/linkml/harbour-core-credential.yaml +++ b/linkml/harbour-core-credential.yaml @@ -65,7 +65,7 @@ prefixes: linkml: https://w3id.org/linkml/ harbour: https://w3id.org/reachhaven/harbour/core/v1/ sec: https://w3id.org/security# - sdo: http://schema.org/ + sdo: https://schema.org/ xsd: http://www.w3.org/2001/XMLSchema# rdf: http://www.w3.org/1999/02/22-rdf-syntax-ns# cs: https://www.w3.org/ns/credentials/status# @@ -369,8 +369,8 @@ classes: # Harbour credential types add mandatory credentialStatus (CRSetEntry) # and optional evidence to W3C VerifiableCredential. # [VC-JOSE-COSE] §3.1.1 — full VC JSON-LD becomes JWT payload (no vc wrapper). - # [SD-JWT-VC] §11 — SD-JWT-VC does NOT use W3C VCDM; flat claims. - # Harbour claim_mapping.py bridges W3C ↔ SD-JWT-VC formats. + # [SD-JWT-VC] §11 — SD-JWT-VC supports structured nested disclosure per RFC 9901 §6.2. + # Harbour sd_jwt.py handles nested _sd arrays at any nesting level. HarbourCredential: abstract: true diff --git a/linkml/harbour-gx-credential.yaml b/linkml/harbour-gx-credential.yaml index 3bf7afd..403a40c 100644 --- a/linkml/harbour-gx-credential.yaml +++ b/linkml/harbour-gx-credential.yaml @@ -90,7 +90,7 @@ prefixes: linkml: https://w3id.org/linkml/ harbour: https://w3id.org/reachhaven/harbour/core/v1/ harbour.gx: https://w3id.org/reachhaven/harbour/gx/v1/ - sdo: http://schema.org/ + sdo: https://schema.org/ xsd: http://www.w3.org/2001/XMLSchema# # cred is needed for SHACL prefix binding of inherited W3C VC envelope # slots (issuer, validFrom, validUntil, credentialStatus, evidence). @@ -403,7 +403,7 @@ classes: # no NaturalPerson type, so Harbour creates one as a sibling of # gx:LegalPerson [GX-ONT-LP] under gx:Participant. # [SCHEMA-ORG] — person-specific attributes (givenName, familyName, - # memberOf) use http://schema.org/ vocabulary. + # memberOf) use https://schema.org/ vocabulary. # [GX-ONT] — email uses gx:email from the Gaia-X vocabulary. HarbourNaturalPerson: @@ -432,17 +432,20 @@ classes: description: Email address of the contact. slot_uri: gx:email range: string - # [SCHEMA-ORG] — http://schema.org/givenName + required: true + # [SCHEMA-ORG] — https://schema.org/givenName givenName: description: First name / given name of the natural person. slot_uri: sdo:givenName range: string - # [SCHEMA-ORG] — http://schema.org/familyName + required: true + # [SCHEMA-ORG] — https://schema.org/familyName familyName: description: Last name / family name of the natural person. slot_uri: sdo:familyName range: string - # [SCHEMA-ORG] — http://schema.org/memberOf + required: true + # [SCHEMA-ORG] — https://schema.org/memberOf # Cross-document reference — the target LegalPerson is in another # credential. range: uri gives sh:nodeKind sh:IRI (SHACL) and # @type: @id (JSON-LD via xsd_anyuri_as_iri flag). diff --git a/pyproject.toml b/pyproject.toml index 9dfb3f3..d1d6a90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,6 @@ dependencies = [ "joserfc>=1.0.0", "cryptography>=44.0.0", "base58>=2.1.0", - "sd-jwt>=0.10.0", ] [project.optional-dependencies] diff --git a/src/python/credentials/__init__.py b/src/python/credentials/__init__.py index da19242..bc87e83 100644 --- a/src/python/credentials/__init__.py +++ b/src/python/credentials/__init__.py @@ -1,24 +1,9 @@ -"""Credentials pipeline - credential processing and claim mapping. +"""Credentials pipeline - credential processing and signing. This package provides tools for: -- Mapping between W3C VCDM and SD-JWT-VC claim formats - Signing example credentials for testing and documentation +- Verifying signed credential artifacts Usage: - python -m credentials.claim_mapping --help python -m credentials.example_signer --help """ - -from credentials.claim_mapping import ( - MAPPINGS, - get_mapping_for_vc, - sd_jwt_claims_to_vc, - vc_to_sd_jwt_claims, -) - -__all__ = [ - "MAPPINGS", - "vc_to_sd_jwt_claims", - "sd_jwt_claims_to_vc", - "get_mapping_for_vc", -] diff --git a/src/python/credentials/claim_mapping.py b/src/python/credentials/claim_mapping.py deleted file mode 100644 index 2c8f216..0000000 --- a/src/python/credentials/claim_mapping.py +++ /dev/null @@ -1,446 +0,0 @@ -"""Generic claim mappings from W3C VCDM JSON-LD to SD-JWT-VC flat claims. - -Provides a framework for mapping between nested JSON-LD (credentialSubject) -and flat SD-JWT-VC claims, plus which claims are selectively disclosable. - -This module provides the core mapping functions. Domain-specific mappings -(e.g., Gaia-X, organizational credentials) can register their own mappings. - -CLI Usage: - python -m credentials.claim_mapping --help - python -m credentials.claim_mapping to-sd-jwt --input vc.json --mapping mapping.json - python -m credentials.claim_mapping from-sd-jwt --input claims.json --mapping mapping.json -""" - -import argparse -import json -import sys -from pathlib import Path -from typing import Any - -# --------------------------------------------------------------------------- -# Harbour Credential Mappings -# --------------------------------------------------------------------------- - -# Harbour namespace -HARBOUR_NS = "https://w3id.org/reachhaven/harbour/core/v1/" - -# Harbour Gaia-X domain namespace -HARBOUR_GX_NS = "https://w3id.org/reachhaven/harbour/gx/v1/" - -# Gaia-X namespace -GAIAX_NS = "https://w3id.org/gaia-x/development#" - -# --------------------------------------------------------------------------- -# Base harbour mappings (skeleton credentials — no Gaia-X) -# --------------------------------------------------------------------------- - -HARBOUR_LEGAL_PERSON_MAPPING = { - "vct": f"{HARBOUR_GX_NS}LegalPersonCredential", - "claims": { - r"credentialSubject.harbour\.gx:labelLevel": "labelLevel", - r"credentialSubject.harbour\.gx:engineVersion": "engineVersion", - r"credentialSubject.harbour\.gx:rulesVersion": "rulesVersion", - }, - "always_disclosed": ["iss", "vct", "iat", "exp", "labelLevel"], - "selectively_disclosed": [ - "engineVersion", - "rulesVersion", - ], -} - -HARBOUR_NATURAL_PERSON_MAPPING = { - "vct": f"{HARBOUR_GX_NS}NaturalPersonCredential", - "claims": { - "credentialSubject.givenName": "givenName", - "credentialSubject.familyName": "familyName", - "credentialSubject.email": "email", - "credentialSubject.memberOf": "memberOf", - }, - "always_disclosed": ["iss", "vct", "iat", "exp"], - "selectively_disclosed": ["givenName", "familyName", "email", "memberOf"], -} - -# --------------------------------------------------------------------------- -# Gaia-X domain mappings (with participant inner node) -# Used when the credential wraps gx data inside a participant nested object. -# --------------------------------------------------------------------------- - -GAIAX_LEGAL_PERSON_MAPPING = { - "vct": f"{HARBOUR_GX_NS}LegalPersonCredential", - "claims": { - "credentialSubject.participant.name": "legalName", - "credentialSubject.participant.gx:registrationNumber": "registrationNumber", - "credentialSubject.participant.gx:headquartersAddress": "headquartersAddress", - "credentialSubject.participant.gx:legalAddress": "legalAddress", - }, - "always_disclosed": ["iss", "vct", "iat", "exp", "legalName"], - "selectively_disclosed": [ - "registrationNumber", - "headquartersAddress", - "legalAddress", - ], -} - -GAIAX_NATURAL_PERSON_MAPPING = { - "vct": f"{HARBOUR_GX_NS}NaturalPersonCredential", - "claims": { - "credentialSubject.participant.givenName": "givenName", - "credentialSubject.participant.familyName": "familyName", - "credentialSubject.participant.email": "email", - "credentialSubject.memberOf": "memberOf", - }, - "always_disclosed": ["iss", "vct", "iat", "exp"], - "selectively_disclosed": [ - "givenName", - "familyName", - "email", - "memberOf", - ], -} - -# --------------------------------------------------------------------------- -# Registries -# --------------------------------------------------------------------------- - -# Base harbour mappings (skeleton credentials) -MAPPINGS: dict[str, dict] = { - "harbour.gx:LegalPersonCredential": HARBOUR_LEGAL_PERSON_MAPPING, - "harbour.gx:NaturalPersonCredential": HARBOUR_NATURAL_PERSON_MAPPING, -} - -# Gaia-X domain mappings (extended with participant) -GAIAX_MAPPINGS: dict[str, dict] = { - "harbour.gx:LegalPersonCredential": GAIAX_LEGAL_PERSON_MAPPING, - "harbour.gx:NaturalPersonCredential": GAIAX_NATURAL_PERSON_MAPPING, -} - - -def register_mapping(vc_type: str, mapping: dict) -> None: - """Register a custom credential type mapping. - - Args: - vc_type: The credential type name (e.g., "MyCustomCredential"). - mapping: Mapping dict with keys: vct, claims, always_disclosed, selectively_disclosed. - """ - MAPPINGS[vc_type] = mapping - - -def vc_to_sd_jwt_claims(vc: dict, mapping: dict) -> tuple[dict, list[str]]: - """Convert a JSON-LD object to flat SD-JWT-VC claims. - - Supports both: - - W3C VCDM format (with credentialSubject) - - Gaia-X flat format (with @id, @type at top level) - - Args: - vc: The JSON-LD dict (VC or Gaia-X object). - mapping: Mapping dict with keys: vct, claims, always_disclosed, selectively_disclosed. - - Returns: - Tuple of (flat_claims_dict, disclosable_claim_names). - """ - claims: dict[str, Any] = {} - - # Map issuer (W3C VCDM style) - issuer = vc.get("issuer") - if isinstance(issuer, dict): - claims["iss"] = issuer.get("id", "") - elif isinstance(issuer, str): - claims["iss"] = issuer - - # Map subject ID - support both W3C VCDM and Gaia-X flat format - if "credentialSubject" in vc: - subject = vc.get("credentialSubject", {}) - claims["sub"] = subject.get("id", "") - elif "@id" in vc: - # Gaia-X flat format: @id is the subject - claims["sub"] = vc["@id"] - - # Map validity - if "validFrom" in vc: - claims["iat"] = vc["validFrom"] - if "validUntil" in vc: - claims["exp"] = vc["validUntil"] - - # Map credential-specific claims - for vc_path, flat_name in mapping["claims"].items(): - value = _get_nested(vc, vc_path) - if value is not None: - claims[flat_name] = value - - disclosable = [ - name for name in mapping.get("selectively_disclosed", []) if name in claims - ] - - return claims, disclosable - - -def sd_jwt_claims_to_vc(claims: dict, mapping: dict, vc_type: str) -> dict: - """Convert flat SD-JWT-VC claims back to W3C VCDM JSON-LD structure. - - Args: - claims: Flat claims dict. - mapping: Mapping dict. - vc_type: The VC type (e.g., "LegalParticipantCredential"). - - Returns: - W3C VCDM JSON-LD dict. - """ - vc: dict = { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiableCredential", vc_type], - } - - if "iss" in claims: - vc["issuer"] = {"id": claims["iss"]} - if "iat" in claims: - vc["validFrom"] = claims["iat"] - if "exp" in claims: - vc["validUntil"] = claims["exp"] - - subject: dict = {} - if "sub" in claims: - subject["id"] = claims["sub"] - - # Reverse map - reverse_map = {v: k for k, v in mapping["claims"].items()} - for flat_name, value in claims.items(): - if flat_name in reverse_map: - vc_path = reverse_map[flat_name] - _set_nested(vc, vc_path, value) - - if subject or vc.get("credentialSubject"): - existing = vc.get("credentialSubject", {}) - vc["credentialSubject"] = {**subject, **existing} - - return vc - - -def _has_gaiax_context(vc: dict) -> bool: - """Check whether a VC's @context includes the Gaia-X namespace.""" - ctx = vc.get("@context", []) - if isinstance(ctx, str): - ctx = [ctx] - return GAIAX_NS in ctx - - -def get_mapping_for_vc(vc: dict) -> dict | None: - """Find the matching mapping for a VC based on its type and context. - - Context-aware: if the VC's @context includes the Gaia-X namespace, - returns the Gaia-X mapping; otherwise returns the base harbour mapping. - - Supports both: - - W3C VCDM: "type" array (e.g., ["VerifiableCredential", "PersonCredential"]) - - Gaia-X: "@type" string (e.g., "gx:LegalPerson") - - Args: - vc: The JSON-LD dict. - - Returns: - Matching mapping dict or None if not found. - """ - # Get types from both W3C VCDM and JSON-LD formats - vc_types = vc.get("type", []) - if isinstance(vc_types, str): - vc_types = [vc_types] - - # Also check @type for Gaia-X format - at_type = vc.get("@type") - if at_type: - if isinstance(at_type, str): - vc_types = vc_types + [at_type] - elif isinstance(at_type, list): - vc_types = vc_types + at_type - - # Use primary registry — GAIAX_MAPPINGS is reserved for participant-nested - # patterns (not yet used in current examples). - for vc_type, mapping in MAPPINGS.items(): - if vc_type in vc_types: - return mapping - return None - - -def create_mapping( - vct: str, - claims: dict[str, str], - always_disclosed: list[str] | None = None, - selectively_disclosed: list[str] | None = None, -) -> dict: - """Create a new mapping configuration. - - Args: - vct: The SD-JWT-VC type URI. - claims: Dict mapping JSON-LD paths to flat claim names. - always_disclosed: Claim names that are always disclosed. - selectively_disclosed: Claim names that can be selectively disclosed. - - Returns: - Mapping dict ready for use with vc_to_sd_jwt_claims/sd_jwt_claims_to_vc. - """ - return { - "vct": vct, - "claims": claims, - "always_disclosed": always_disclosed or ["iss", "vct", "iat", "exp"], - "selectively_disclosed": selectively_disclosed or [], - } - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _get_nested(obj: dict, path: str) -> Any: - """Get a nested value by dot-separated path. - - Dots inside keys can be escaped with a backslash (``\\.``). - Unescaped dots are path separators; escaped dots are literal. - Also falls back to greedy key matching for existing keys with dots. - """ - parts = _split_path(path) - current: Any = obj - for part in parts: - if isinstance(current, dict): - current = current.get(part) - else: - return None - return current - - -def _set_nested(obj: dict, path: str, value: Any) -> None: - """Set a nested value by dot-separated path. - - Dots inside keys can be escaped with a backslash (``\\.``). - """ - parts = _split_path(path) - current = obj - for part in parts[:-1]: - if part not in current: - current[part] = {} - current = current[part] - current[parts[-1]] = value - - -def _split_path(path: str) -> list[str]: - r"""Split a dot-delimited path, respecting escaped dots (``\.``). - - ``credentialSubject.harbour\.gx:labelLevel`` → ``["credentialSubject", "harbour.gx:labelLevel"]`` - """ - import re - - parts = re.split(r"(? None: - """CLI entry point for claim mapping.""" - parser = argparse.ArgumentParser( - prog="credentials.claim_mapping", - description="Convert between W3C VCDM and SD-JWT-VC claim formats", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - python -m credentials.claim_mapping to-sd-jwt --input vc.json - python -m credentials.claim_mapping from-sd-jwt --input claims.json --type PersonCredential - python -m credentials.claim_mapping list-types - """, - ) - - subparsers = parser.add_subparsers(dest="command", help="Available commands") - - # to-sd-jwt subcommand - to_parser = subparsers.add_parser( - "to-sd-jwt", - help="Convert W3C VCDM credential to SD-JWT flat claims", - description="Transform a W3C VCDM JSON-LD credential to flat SD-JWT claims.", - ) - to_parser.add_argument("--input", "-i", required=True, help="Input VC JSON file") - to_parser.add_argument("--output", "-o", help="Output file (default: stdout)") - - # from-sd-jwt subcommand - from_parser = subparsers.add_parser( - "from-sd-jwt", - help="Convert SD-JWT flat claims to W3C VCDM format", - description="Transform flat SD-JWT claims back to W3C VCDM JSON-LD format.", - ) - from_parser.add_argument( - "--input", "-i", required=True, help="Input claims JSON file" - ) - from_parser.add_argument( - "--type", "-t", required=True, help="VC type (e.g., PersonCredential)" - ) - from_parser.add_argument("--output", "-o", help="Output file (default: stdout)") - - # list-types subcommand - subparsers.add_parser( - "list-types", - help="List supported credential types", - description="Show all credential types with registered mappings.", - ) - - args = parser.parse_args() - - if args.command is None: - parser.print_help() - sys.exit(0) - - if args.command == "to-sd-jwt": - vc = json.loads(Path(args.input).read_text()) - mapping = get_mapping_for_vc(vc) - if mapping is None: - print(f"No mapping found for VC types: {vc.get('type')}", file=sys.stderr) - sys.exit(1) - - claims, disclosable = vc_to_sd_jwt_claims(vc, mapping) - result = { - "claims": claims, - "disclosable": disclosable, - "vct": mapping["vct"], - } - - output = json.dumps(result, indent=2) - if args.output: - Path(args.output).write_text(output) - print(f"Claims written to {args.output}", file=sys.stderr) - else: - print(output) - - elif args.command == "from-sd-jwt": - data = json.loads(Path(args.input).read_text()) - claims = data.get("claims", data) # Support both wrapped and raw claims - vc_type = args.type - mapping = MAPPINGS.get(vc_type) - if mapping is None: - print(f"Unknown VC type: {vc_type}", file=sys.stderr) - print(f"Available: {', '.join(MAPPINGS.keys())}", file=sys.stderr) - sys.exit(1) - - vc = sd_jwt_claims_to_vc(claims, mapping, vc_type) - output = json.dumps(vc, indent=2) - if args.output: - Path(args.output).write_text(output) - print(f"VC written to {args.output}", file=sys.stderr) - else: - print(output) - - elif args.command == "list-types": - print("Supported credential types:") - print("\nBase harbour mappings:") - for type_key, mapping in MAPPINGS.items(): - print(f" {type_key}") - print(f" vct: {mapping['vct']}") - print("\nGaia-X domain mappings:") - for type_key, mapping in GAIAX_MAPPINGS.items(): - print(f" {type_key}") - print(f" vct: {mapping['vct']}") - - -if __name__ == "__main__": - main() diff --git a/src/python/harbour/generate_artifacts.py b/src/python/harbour/generate_artifacts.py index 915a199..05cd0f0 100644 --- a/src/python/harbour/generate_artifacts.py +++ b/src/python/harbour/generate_artifacts.py @@ -63,7 +63,8 @@ def main() -> None: print(f" Processing {domain}...") owl_gen = OwlSchemaGenerator( - schema, mergeimports=False, importmap=importmap, base_dir=base_dir + schema, mergeimports=False, deterministic=True, + importmap=importmap, base_dir=base_dir ) owl_text = owl_gen.serialize() @@ -79,7 +80,8 @@ def main() -> None: if domain not in SHACL_SKIP_DOMAINS: shacl_gen = ShaclGenerator( - schema, importmap=importmap, base_dir=base_dir, + schema, deterministic=True, + importmap=importmap, base_dir=base_dir, ) (out_dir / f"{domain}.shacl.ttl").write_text( shacl_gen.serialize(), encoding="utf-8" @@ -88,7 +90,9 @@ def main() -> None: ctx_gen = ContextGenerator( schema, mergeimports=False, + exclude_external_imports=True, xsd_anyuri_as_iri=True, + deterministic=True, importmap=importmap, base_dir=base_dir, ) diff --git a/src/python/harbour/sd_jwt.py b/src/python/harbour/sd_jwt.py index 37826fa..f0a1191 100644 --- a/src/python/harbour/sd_jwt.py +++ b/src/python/harbour/sd_jwt.py @@ -1,7 +1,11 @@ """SD-JWT-VC — IETF SD-JWT-based Verifiable Credentials. Provides issuance and verification of SD-JWT-VC credentials with selective -disclosure, using ES256 (P-256) or EdDSA (Ed25519) algorithms. +disclosure per RFC 9901, using ES256 (P-256) or EdDSA (Ed25519) algorithms. + +Supports both flat and structured (nested) selective disclosure: + - Flat: ``disclosable=["email", "duns"]`` — top-level claims + - Structured: ``disclosable=["credentialSubject.email"]`` — nested paths CLI Usage: python -m harbour.sd_jwt --help @@ -11,11 +15,13 @@ import argparse import base64 +import copy import hashlib import json import secrets import sys from pathlib import Path +from typing import Any from joserfc import jws @@ -30,6 +36,73 @@ SD_JWT_SEPARATOR = "~" +def _create_disclosure(claim_name: str, claim_value: Any) -> tuple[str, str]: + """Create a single SD-JWT disclosure. + + Returns: + Tuple of (base64url-encoded disclosure, base64url-encoded SHA-256 digest). + """ + salt = secrets.token_urlsafe(16) + disclosure_array = [salt, claim_name, claim_value] + disclosure_json = json.dumps(disclosure_array, ensure_ascii=False).encode("utf-8") + disclosure_b64 = base64.urlsafe_b64encode(disclosure_json).rstrip(b"=").decode() + digest = ( + base64.urlsafe_b64encode( + hashlib.sha256(disclosure_b64.encode("ascii")).digest() + ) + .rstrip(b"=") + .decode() + ) + return disclosure_b64, digest + + +def _apply_structured_disclosures( + payload: dict, disclosable: list[str] +) -> tuple[dict, list[str]]: + """Apply structured selective disclosure to a nested payload. + + Processes dot-path disclosable entries (e.g. ``"credentialSubject.email"``) + by placing ``_sd`` digests at the correct nesting level per RFC 9901 §6.2. + + Simple (non-dotted) names are treated as top-level disclosable claims + for backward compatibility. + + Args: + payload: The claims dict (will be deep-copied, not mutated). + disclosable: List of claim paths (dot-separated for nested). + + Returns: + Tuple of (modified payload with _sd arrays, list of disclosure strings). + """ + result = copy.deepcopy(payload) + disclosures: list[str] = [] + + for path in disclosable: + parts = path.split(".") + leaf_key = parts[-1] + parent_parts = parts[:-1] + + # Navigate to the parent object + parent = result + for part in parent_parts: + if isinstance(parent, dict) and part in parent: + parent = parent[part] + else: + break + else: + # Successfully navigated to parent — check leaf exists + if isinstance(parent, dict) and leaf_key in parent: + value = parent.pop(leaf_key) + disc_b64, digest = _create_disclosure(leaf_key, value) + disclosures.append(disc_b64) + parent.setdefault("_sd", []).append(digest) + continue + + # Path not found — skip silently (claim may not be present) + + return result, disclosures + + def issue_sd_jwt_vc( claims: dict, private_key: PrivateKey, @@ -40,57 +113,37 @@ def issue_sd_jwt_vc( x5c: list[str] | None = None, cnf: dict | None = None, ) -> str: - """Issue an SD-JWT-VC credential. + """Issue an SD-JWT-VC credential with selective disclosure. + + Supports both flat and structured (nested) claims per RFC 9901 §6: + - Flat: ``claims={"email": "a@b.com"}, disclosable=["email"]`` + - Structured: ``claims={"credentialSubject": {"email": "a@b.com"}}, + disclosable=["credentialSubject.email"]`` Args: - claims: The credential claims (flat key-value pairs). + claims: Credential claims dict (flat or nested). private_key: Issuer's private key (P-256 or Ed25519). vct: Verifiable Credential Type URI. - disclosable: List of claim names to make selectively disclosable. + disclosable: Claim names/paths to make selectively disclosable. + Use dot-separated paths for nested claims. alg: Algorithm override (default: ES256 for P-256). x5c: X.509 certificate chain for JOSE header. cnf: Confirmation key (holder's public key JWK for key binding). Returns: - SD-JWT compact string: ~~...~ + SD-JWT compact string: ``~~...~`` """ alg = _resolve_alg(private_key, alg) disclosable = disclosable or [] - # Separate disclosable and always-disclosed claims - sd_claims = {} - disclosed_claims = {"vct": vct} - disclosures = [] - - for key, value in claims.items(): - if key in disclosable: - # Create a disclosure: [salt, claim_name, claim_value] - salt = secrets.token_urlsafe(16) - disclosure_array = [salt, key, value] - disclosure_json = json.dumps(disclosure_array, ensure_ascii=False).encode( - "utf-8" - ) - disclosure_b64 = ( - base64.urlsafe_b64encode(disclosure_json).rstrip(b"=").decode() - ) - disclosures.append(disclosure_b64) - - # Hash the disclosure for the SD digest array - digest = ( - base64.urlsafe_b64encode( - hashlib.sha256(disclosure_b64.encode("ascii")).digest() - ) - .rstrip(b"=") - .decode() - ) - sd_claims.setdefault("_sd", []).append(digest) - else: - disclosed_claims[key] = value + # Build the base payload with vct + payload = {**claims, "vct": vct} - # Build JWT payload - payload = {**disclosed_claims} - if "_sd" in sd_claims: - payload["_sd"] = sd_claims["_sd"] + # Apply structured disclosures (handles both flat and nested paths) + payload, disclosures = _apply_structured_disclosures(payload, disclosable) + + # Set _sd_alg if any disclosures were created + if disclosures: payload["_sd_alg"] = "sha-256" if cnf is not None: @@ -111,6 +164,53 @@ def issue_sd_jwt_vc( return SD_JWT_SEPARATOR.join(parts) +def _collect_sd_digests(obj: Any) -> set[str]: + """Recursively collect all _sd digests from a nested payload.""" + digests: set[str] = set() + if isinstance(obj, dict): + digests.update(obj.get("_sd", [])) + for v in obj.values(): + digests.update(_collect_sd_digests(v)) + elif isinstance(obj, list): + for item in obj: + digests.update(_collect_sd_digests(item)) + return digests + + +def _insert_disclosure_recursive(obj: dict, claim_name: str, claim_value: Any, digest: str) -> bool: + """Recursively find the _sd array containing this digest and insert the claim. + + Returns True if the digest was found and the claim was inserted. + """ + if isinstance(obj, dict): + sd_array = obj.get("_sd", []) + if digest in sd_array: + obj[claim_name] = claim_value + sd_array.remove(digest) + if not sd_array: + del obj["_sd"] + return True + # Recurse into nested objects + for v in obj.values(): + if isinstance(v, dict): + if _insert_disclosure_recursive(v, claim_name, claim_value, digest): + return True + return False + + +def _clean_sd_metadata(obj: Any) -> Any: + """Remove remaining _sd arrays and _sd_alg from the processed payload.""" + if isinstance(obj, dict): + return { + k: _clean_sd_metadata(v) + for k, v in obj.items() + if k not in ("_sd", "_sd_alg") + } + elif isinstance(obj, list): + return [_clean_sd_metadata(item) for item in obj] + return obj + + def verify_sd_jwt_vc( sd_jwt: str, public_key: PublicKeyType, @@ -119,13 +219,17 @@ def verify_sd_jwt_vc( ) -> dict: """Verify an SD-JWT-VC and return all disclosed claims. + Supports recursive ``_sd`` processing per RFC 9901 §7.1: digests may + appear at any nesting level in the payload. + Args: - sd_jwt: SD-JWT compact string (~~...~). + sd_jwt: SD-JWT compact string (``~~...~``). public_key: Issuer's public key (P-256 or Ed25519). expected_vct: If provided, verify the vct claim matches. Returns: - Dict with all disclosed claims (always-disclosed + selectively-disclosed). + Dict with all disclosed claims (always-disclosed + selectively-disclosed), + preserving the original nesting structure. Raises: VerificationError: If signature is invalid or disclosures don't match. @@ -162,24 +266,23 @@ def verify_sd_jwt_vc( f"VCT mismatch: expected {expected_vct!r}, got {payload.get('vct')!r}" ) - # Process disclosures - sd_digests = set(payload.get("_sd", [])) - disclosed_claims = {k: v for k, v in payload.items() if k not in ("_sd", "_sd_alg")} + # Collect all _sd digests recursively + all_digests = _collect_sd_digests(payload) + # Process each disclosure: find its matching _sd digest and insert for disc_b64 in disclosure_strings: - # Verify this disclosure matches a digest in _sd disc_hash = ( base64.urlsafe_b64encode(hashlib.sha256(disc_b64.encode("ascii")).digest()) .rstrip(b"=") .decode() ) - if disc_hash not in sd_digests: + if disc_hash not in all_digests: raise VerificationError( f"Disclosure hash {disc_hash[:16]}... not found in _sd digests" ) - sd_digests.discard(disc_hash) + all_digests.discard(disc_hash) - # Decode and extract claim + # Decode disclosure disc_json = base64.urlsafe_b64decode(disc_b64 + "=" * (-len(disc_b64) % 4)) disc_array = json.loads(disc_json) if len(disc_array) != 3: @@ -187,9 +290,15 @@ def verify_sd_jwt_vc( "Invalid disclosure format: expected [salt, name, value]" ) _, claim_name, claim_value = disc_array - disclosed_claims[claim_name] = claim_value - return disclosed_claims + # Insert the claim at the correct nesting level + if not _insert_disclosure_recursive(payload, claim_name, claim_value, disc_hash): + raise VerificationError( + f"Could not locate _sd digest for claim {claim_name!r}" + ) + + # Clean up _sd metadata from the result + return _clean_sd_metadata(payload) def main(): diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index 98182c9..941c42d 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit 98182c9e60715657363a86e7fdb15de6a337e8a2 +Subproject commit 941c42d86048717b4890ffa981a2ddfe107d2266 diff --git a/submodules/w3id.org b/submodules/w3id.org index ac4b788..4ac1b00 160000 --- a/submodules/w3id.org +++ b/submodules/w3id.org @@ -1 +1 @@ -Subproject commit ac4b788c2ab3187cb0210d15a67ebf3d7b765c68 +Subproject commit 4ac1b00ec371020430e070a3babf08e295cf0a97 diff --git a/tests/python/credentials/test_claim_mapping.py b/tests/python/credentials/test_claim_mapping.py deleted file mode 100644 index fc22b6c..0000000 --- a/tests/python/credentials/test_claim_mapping.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Tests for claim mappings: W3C VCDM JSON-LD <-> SD-JWT-VC flat claims.""" - -import json -from pathlib import Path - -from credentials.claim_mapping import ( - GAIAX_NS, - MAPPINGS, - create_mapping, - get_mapping_for_vc, - register_mapping, - sd_jwt_claims_to_vc, - vc_to_sd_jwt_claims, -) - -_REPO_ROOT = Path(__file__).resolve().parent -while _REPO_ROOT.name != "harbour-credentials" and _REPO_ROOT != _REPO_ROOT.parent: - _REPO_ROOT = _REPO_ROOT.parent - -EXAMPLES_DIR = _REPO_ROOT / "examples" -GAIAX_EXAMPLES_DIR = EXAMPLES_DIR / "gaiax" - - -def _load_fixture(name: str) -> dict: - """Load a credential example from the gaiax examples directory.""" - with open(GAIAX_EXAMPLES_DIR / name) as f: - return json.load(f) - - -# --------------------------------------------------------------------------- -# Gaia-X domain tests (primary — all domain examples live in gaiax/) -# --------------------------------------------------------------------------- - - -class TestGaiaxLegalPersonMapping: - def test_vc_to_claims(self): - vc = _load_fixture("legal-person-credential.json") - mapping = MAPPINGS["harbour.gx:LegalPersonCredential"] - claims, disclosable = vc_to_sd_jwt_claims(vc, mapping) - - assert ( - claims["iss"] - == "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202" - ) - assert ( - claims["sub"] - == "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93" - ) - assert claims["labelLevel"] == "SC" - assert "engineVersion" in claims - assert "engineVersion" in disclosable - - def test_has_credential_status(self): - vc = _load_fixture("legal-person-credential.json") - assert "credentialStatus" in vc - status = vc["credentialStatus"][0] - assert status["type"] == "harbour:CRSetEntry" - assert status["statusPurpose"] == "revocation" - - def test_subject_is_harbour_gx_legal_person(self): - """Verify the subject uses harbour.gx:LegalPerson.""" - vc = _load_fixture("legal-person-credential.json") - subject_type = vc["credentialSubject"]["type"] - assert subject_type == "harbour.gx:LegalPerson" - - def test_has_gaiax_context(self): - """Gaia-X extension must include the Gaia-X namespace in @context.""" - vc = _load_fixture("legal-person-credential.json") - assert GAIAX_NS in vc["@context"] - - def test_roundtrip(self): - vc = _load_fixture("legal-person-credential.json") - mapping = MAPPINGS["harbour.gx:LegalPersonCredential"] - claims, _ = vc_to_sd_jwt_claims(vc, mapping) - reconstructed = sd_jwt_claims_to_vc( - claims, mapping, "harbour.gx:LegalPersonCredential" - ) - assert ( - reconstructed["credentialSubject"]["harbour.gx:labelLevel"] - == vc["credentialSubject"]["harbour.gx:labelLevel"] - ) - - -class TestGaiaxNaturalPersonMapping: - def test_vc_to_claims(self): - vc = _load_fixture("natural-person-credential.json") - mapping = MAPPINGS["harbour.gx:NaturalPersonCredential"] - claims, disclosable = vc_to_sd_jwt_claims(vc, mapping) - - assert claims["givenName"] == "Alice" - assert claims["familyName"] == "Smith" - assert "givenName" in disclosable - assert ( - claims["memberOf"] - == "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93" - ) - - def test_has_credential_status(self): - vc = _load_fixture("natural-person-credential.json") - assert "credentialStatus" in vc - status = vc["credentialStatus"][0] - assert status["type"] == "harbour:CRSetEntry" - - def test_has_evidence(self): - vc = _load_fixture("natural-person-credential.json") - assert "evidence" in vc - evidence = vc["evidence"][0] - ev_type = evidence["type"] - if isinstance(ev_type, list): - assert "harbour:CredentialEvidence" in ev_type - else: - assert ev_type == "harbour:CredentialEvidence" - - def test_subject_is_harbour_gx_natural_person(self): - """Verify the subject uses harbour.gx:NaturalPerson.""" - vc = _load_fixture("natural-person-credential.json") - subject_type = vc["credentialSubject"]["type"] - assert subject_type == "harbour.gx:NaturalPerson" - - def test_has_gaiax_context(self): - """Gaia-X extension must include the Gaia-X namespace in @context.""" - vc = _load_fixture("natural-person-credential.json") - assert GAIAX_NS in vc["@context"] - - -# --------------------------------------------------------------------------- -# Context-aware mapping discovery -# --------------------------------------------------------------------------- - - -class TestMappingDiscovery: - def test_get_mapping_for_gaiax_legal_person(self): - """Gaia-X legal person should return the flat mapping.""" - vc = _load_fixture("legal-person-credential.json") - mapping = get_mapping_for_vc(vc) - assert mapping is not None - assert "LegalPersonCredential" in mapping["vct"] - assert "credentialSubject.harbour\\.gx:labelLevel" in mapping["claims"] - - def test_get_mapping_for_gaiax_natural_person(self): - """Gaia-X natural person should return the flat mapping.""" - vc = _load_fixture("natural-person-credential.json") - mapping = get_mapping_for_vc(vc) - assert mapping is not None - assert "NaturalPersonCredential" in mapping["vct"] - assert "credentialSubject.givenName" in mapping["claims"] - - def test_get_mapping_for_unknown(self): - vc = {"type": ["VerifiableCredential", "UnknownType"]} - mapping = get_mapping_for_vc(vc) - assert mapping is None - - -class TestCustomMapping: - def test_register_and_use_custom_mapping(self): - # Create a custom mapping - custom = create_mapping( - vct="https://example.com/credentials/CustomCredential", - claims={ - "credentialSubject.customField": "customField", - }, - selectively_disclosed=["customField"], - ) - - # Register it - register_mapping("harbour:CustomCredential", custom) - - # Use it - vc = { - "type": ["VerifiableCredential", "harbour:CustomCredential"], - "issuer": "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", - "validFrom": "2024-01-01T00:00:00Z", - "credentialSubject": { - "id": "did:ethr:0x14a34:0xe21cf53752b534301cd285712734ab1710260543", - "customField": "custom-value", - }, - "credentialStatus": [ - { - "id": "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c:revocation#abc123", - "type": "harbour:CRSetEntry", - "statusPurpose": "revocation", - } - ], - } - - mapping = get_mapping_for_vc(vc) - assert mapping is not None - - claims, disclosable = vc_to_sd_jwt_claims(vc, mapping) - assert claims["customField"] == "custom-value" - assert "customField" in disclosable - - # Clean up - del MAPPINGS["harbour:CustomCredential"] diff --git a/tests/python/harbour/test_sd_jwt.py b/tests/python/harbour/test_sd_jwt.py index 82e52c7..6f2d835 100644 --- a/tests/python/harbour/test_sd_jwt.py +++ b/tests/python/harbour/test_sd_jwt.py @@ -169,3 +169,177 @@ def test_cnf_with_ed25519(self, ed25519_private_key, ed25519_public_key): ) result = verify_sd_jwt_vc(sd_jwt, ed25519_public_key) assert result["cnf"]["jwk"]["crv"] == "Ed25519" + + +# --------------------------------------------------------------------------- +# Structured (nested) selective disclosure — RFC 9901 §6.2 +# --------------------------------------------------------------------------- + +NESTED_CLAIMS = { + "iss": "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", + "iat": 1723972522, + "exp": 1913990400, + "credentialSubject": { + "id": "did:ethr:0x14a34:0x9d273DCaC2f6367968d61caf69A7E3177fd81048", + "harbourCredential": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "legalForm": "AG", + "duns": "313995269", + "email": "imprint@bmw.com", + "url": "https://www.bmwgroup.com/", + "gxParticipant": { + "name": "Bayerische Motoren Werke Aktiengesellschaft", + }, + }, +} + +NESTED_VCT = "https://w3id.org/ascs-ev/simpulse-id/v1/ParticipantCredential" + + +class TestStructuredDisclosure: + """Structured SD-JWT with _sd at nested levels per RFC 9901 §6.2.""" + + def test_nested_disclosure_issue_format(self, p256_private_key): + """Nested disclosable paths produce disclosures.""" + sd_jwt = issue_sd_jwt_vc( + NESTED_CLAIMS, + p256_private_key, + vct=NESTED_VCT, + disclosable=[ + "credentialSubject.email", + "credentialSubject.duns", + "credentialSubject.url", + ], + ) + parts = sd_jwt.split("~") + # issuer-jwt + 3 disclosures + trailing empty = 5 parts + assert len(parts) == 5 + + def test_nested_disclosure_verify_all(self, p256_private_key, p256_public_key): + """All disclosures present → full nested structure reconstructed.""" + sd_jwt = issue_sd_jwt_vc( + NESTED_CLAIMS, + p256_private_key, + vct=NESTED_VCT, + disclosable=[ + "credentialSubject.email", + "credentialSubject.duns", + ], + ) + result = verify_sd_jwt_vc(sd_jwt, p256_public_key) + + # Always-disclosed nested claims preserved + assert result["credentialSubject"]["legalForm"] == "AG" + assert result["credentialSubject"]["gxParticipant"]["name"] == ( + "Bayerische Motoren Werke Aktiengesellschaft" + ) + # Selectively-disclosed claims present (all disclosures provided) + assert result["credentialSubject"]["email"] == "imprint@bmw.com" + assert result["credentialSubject"]["duns"] == "313995269" + + def test_nested_partial_disclosure(self, p256_private_key, p256_public_key): + """Remove one nested disclosure → holder hides a claim.""" + sd_jwt = issue_sd_jwt_vc( + NESTED_CLAIMS, + p256_private_key, + vct=NESTED_VCT, + disclosable=[ + "credentialSubject.email", + "credentialSubject.duns", + "credentialSubject.url", + ], + ) + # Remove first two disclosures, keep only the third + parts = sd_jwt.split("~") + partial = f"{parts[0]}~{parts[3]}~" + result = verify_sd_jwt_vc(partial, p256_public_key) + + # Structure preserved, always-disclosed claims present + assert result["credentialSubject"]["legalForm"] == "AG" + # Only one of the three disclosable claims should be present + sd_keys = {"email", "duns", "url"} + present = sd_keys & set(result["credentialSubject"].keys()) + assert len(present) == 1 + + def test_mixed_flat_and_nested(self, p256_private_key, p256_public_key): + """Mix of top-level and nested disclosable paths.""" + claims = { + "iss": "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", + "topSecret": "classified", + "nested": { + "sensitive": "hidden-value", + "public": "visible", + }, + } + sd_jwt = issue_sd_jwt_vc( + claims, + p256_private_key, + vct=VCT, + disclosable=["topSecret", "nested.sensitive"], + ) + result = verify_sd_jwt_vc(sd_jwt, p256_public_key) + + assert result["topSecret"] == "classified" + assert result["nested"]["sensitive"] == "hidden-value" + assert result["nested"]["public"] == "visible" + + def test_nested_disclosure_preserves_always_disclosed( + self, p256_private_key, p256_public_key + ): + """Non-disclosable nested claims stay in cleartext.""" + sd_jwt = issue_sd_jwt_vc( + NESTED_CLAIMS, + p256_private_key, + vct=NESTED_VCT, + disclosable=["credentialSubject.email"], + ) + # Remove the email disclosure + parts = sd_jwt.split("~") + no_disclosures = f"{parts[0]}~" + result = verify_sd_jwt_vc(no_disclosures, p256_public_key) + + # All non-disclosable claims preserved + cs = result["credentialSubject"] + assert cs["id"] == "did:ethr:0x14a34:0x9d273DCaC2f6367968d61caf69A7E3177fd81048" + assert cs["harbourCredential"] == "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890" + assert cs["legalForm"] == "AG" + assert cs["duns"] == "313995269" + assert cs["url"] == "https://www.bmwgroup.com/" + assert cs["gxParticipant"]["name"] == "Bayerische Motoren Werke Aktiengesellschaft" + # Email should NOT be present (disclosure was removed) + assert "email" not in cs + + def test_nonexistent_path_ignored(self, p256_private_key, p256_public_key): + """Disclosable path that doesn't exist in claims is silently skipped.""" + sd_jwt = issue_sd_jwt_vc( + NESTED_CLAIMS, + p256_private_key, + vct=NESTED_VCT, + disclosable=["credentialSubject.nonexistent"], + ) + result = verify_sd_jwt_vc(sd_jwt, p256_public_key) + # No disclosures created, all claims present as always-disclosed + assert result["credentialSubject"]["email"] == "imprint@bmw.com" + + def test_sd_alg_at_root_only(self, p256_private_key, p256_public_key): + """_sd_alg should appear only at root level, not in nested objects.""" + import base64 + import json + + sd_jwt = issue_sd_jwt_vc( + NESTED_CLAIMS, + p256_private_key, + vct=NESTED_VCT, + disclosable=["credentialSubject.email"], + ) + # Decode the issuer JWT payload + issuer_jwt = sd_jwt.split("~")[0] + payload_b64 = issuer_jwt.split(".")[1] + payload = json.loads( + base64.urlsafe_b64decode(payload_b64 + "=" * (-len(payload_b64) % 4)) + ) + # _sd_alg at root + assert payload["_sd_alg"] == "sha-256" + # _sd inside credentialSubject (where disclosure lives) + assert "_sd" in payload["credentialSubject"] + # NO _sd_alg inside credentialSubject + assert "_sd_alg" not in payload["credentialSubject"] diff --git a/tests/python/harbour/test_sd_jwt_vp.py b/tests/python/harbour/test_sd_jwt_vp.py index 25e0783..cbcaf84 100644 --- a/tests/python/harbour/test_sd_jwt_vp.py +++ b/tests/python/harbour/test_sd_jwt_vp.py @@ -61,7 +61,7 @@ def sample_sd_jwt_vc(issuer_keypair, holder_keypair): holder_private, holder_public = holder_keypair holder_did = p256_public_key_to_did_key(holder_public) - # SD-JWT-VC uses flat claims (not nested credentialSubject) + # SD-JWT-VC claims (flat or nested per RFC 9901 §6) claims = { "iss": "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", "sub": holder_did, @@ -523,7 +523,7 @@ def test_delegated_consent_flow(self, issuer_keypair, holder_keypair): holder_private, holder_public = holder_keypair holder_did = p256_public_key_to_did_key(holder_public) - # Step 1: Issue credential to holder (SD-JWT-VC uses flat claims) + # Step 1: Issue credential to holder claims = { "iss": "did:ethr:0x14a34:0xb0771a9447399cd33e0cad1228a33ac914715105", "sub": holder_did, @@ -619,7 +619,7 @@ def test_public_audit_privacy(self, issuer_keypair, holder_keypair): holder_private, holder_public = holder_keypair holder_did = p256_public_key_to_did_key(holder_public) - # Issue credential with PII (SD-JWT-VC uses flat claims) + # Issue credential with PII claims = { "iss": "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", "sub": holder_did, From 5573d6ea9fb8d171efd31f994c17abbad6e96f79 Mon Sep 17 00:00:00 2001 From: jdsika Date: Thu, 26 Mar 2026 22:51:21 +0100 Subject: [PATCH 43/78] fix: update docs, deps, and regenerate with normalize-prefixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix installation.md: correct fork URL (ASCS-eV → reachhaven) - Fix quickstart.md: correct SD-JWT parameter (disclosable_claims → disclosable), add vct - Add pycryptodome>=3.20 dependency to pyproject.toml - Guard interop test npx check with try/except - Add rdfs:label slot to harbour-core-credential.yaml for Organization shape - Add normalize_prefixes=True to generate_artifacts.py (all generators) - Pin ontology-management-base submodule to chore/cleanup-deterministic-pipeline Signed-off-by: jdsika --- docs/getting-started/installation.md | 4 ++-- docs/getting-started/quickstart.md | 12 ++++++----- examples/credential-with-evidence.json | 12 +++++------ examples/credential-with-nested-evidence.json | 12 +++++------ .../did-ethr/harbour-signing-service.did.json | 6 +++--- .../did-ethr/harbour-trust-anchor.did.json | 2 +- examples/gaiax/delegated-signing-receipt.json | 13 ++++++------ .../legal-person-credential-embedded.json | 10 +++++----- examples/gaiax/legal-person-credential.json | 10 +++++----- examples/gaiax/natural-person-credential.json | 18 ++++++++--------- examples/gaiax/participant-vp.json | 12 +++++------ examples/gaiax/trust-anchor-credential.json | 6 +++--- linkml/harbour-core-credential.yaml | 20 ++++++++++++++++--- pyproject.toml | 1 + src/python/harbour/generate_artifacts.py | 8 ++++++++ submodules/ontology-management-base | 2 +- tests/interop/test_cross_runtime.py | 19 ++++++++++-------- 17 files changed, 98 insertions(+), 69 deletions(-) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index f9d59bb..8702779 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -11,7 +11,7 @@ pip install harbour-credentials ### From Source ```bash -git clone https://github.com/ASCS-eV/harbour-credentials.git +git clone https://github.com/reachhaven/harbour-credentials.git cd harbour-credentials # Create virtual environment @@ -45,7 +45,7 @@ npm install @reachhaven/harbour-credentials ### From Source ```bash -git clone https://github.com/ASCS-eV/harbour-credentials.git +git clone https://github.com/reachhaven/harbour-credentials.git cd harbour-credentials/src/typescript/harbour corepack yarn install diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 62a3be7..0931919 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -101,7 +101,8 @@ from harbour.sd_jwt import issue_sd_jwt_vc, verify_sd_jwt_vc sd_jwt = issue_sd_jwt_vc( credential, private_key, - disclosable_claims=["name", "email"] + vct="https://example.com/credential/v1", + disclosable=["name", "email"], ) # Verify @@ -111,13 +112,14 @@ result = verify_sd_jwt_vc(sd_jwt, public_key) **TypeScript:** ```typescript -import { issueSdJwt, verifySdJwt } from '@reachhaven/harbour-credentials'; +import { issueSdJwtVc, verifySdJwtVc } from '@reachhaven/harbour-credentials'; -const sdJwt = await issueSdJwt(credential, privateKey, { - disclosableClaims: ['name', 'email'] +const sdJwt = await issueSdJwtVc(credential, privateKey, { + vct: 'https://example.com/credential/v1', + disclosable: ['name', 'email'], }); -const result = await verifySdJwt(sdJwt, publicKey); +const result = await verifySdJwtVc(sdJwt, publicKey); ``` ## Next Steps diff --git a/examples/credential-with-evidence.json b/examples/credential-with-evidence.json index e6a029a..69b8ed0 100644 --- a/examples/credential-with-evidence.json +++ b/examples/credential-with-evidence.json @@ -5,7 +5,7 @@ ], "type": [ "VerifiableCredential", - "harbour:VerifiableCredential" + "HarbourVerifiableCredential" ], "id": "urn:uuid:11111111-1111-1111-1111-111111111111", "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", @@ -17,15 +17,15 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/a1b2c3d4e5f67890", - "type": "harbour:CRSetEntry", + "type": "CRSetEntry", "statusPurpose": "revocation" } ], "evidence": [ { "type": [ - "harbour:Evidence", - "harbour:CredentialEvidence" + "Evidence", + "CredentialEvidence" ], "verifiablePresentation": { "@context": [ @@ -33,7 +33,7 @@ ], "type": [ "VerifiablePresentation", - "harbour:VerifiablePresentation" + "HarbourVerifiablePresentation" ], "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "verifiableCredential": [ @@ -49,7 +49,7 @@ "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", - "type": "harbour:LinkedCredentialService", + "type": "LinkedCredentialService", "didcore:serviceEndpoint": { "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" } diff --git a/examples/credential-with-nested-evidence.json b/examples/credential-with-nested-evidence.json index 3dab435..1974a03 100644 --- a/examples/credential-with-nested-evidence.json +++ b/examples/credential-with-nested-evidence.json @@ -5,7 +5,7 @@ ], "type": [ "VerifiableCredential", - "harbour:VerifiableCredential" + "HarbourVerifiableCredential" ], "id": "urn:uuid:22222222-2222-2222-2222-222222222222", "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", @@ -17,15 +17,15 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/b2c3d4e5f6a78901", - "type": "harbour:CRSetEntry", + "type": "CRSetEntry", "statusPurpose": "revocation" } ], "evidence": [ { "type": [ - "harbour:Evidence", - "harbour:CredentialEvidence" + "Evidence", + "CredentialEvidence" ], "verifiablePresentation": { "@context": [ @@ -33,7 +33,7 @@ ], "type": [ "VerifiablePresentation", - "harbour:VerifiablePresentation" + "HarbourVerifiablePresentation" ], "holder": "did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "verifiableCredential": [ @@ -49,7 +49,7 @@ "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", - "type": "harbour:LinkedCredentialService", + "type": "LinkedCredentialService", "didcore:serviceEndpoint": { "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" } diff --git a/examples/did-ethr/harbour-signing-service.did.json b/examples/did-ethr/harbour-signing-service.did.json index 036f554..4090dfd 100644 --- a/examples/did-ethr/harbour-signing-service.did.json +++ b/examples/did-ethr/harbour-signing-service.did.json @@ -47,7 +47,7 @@ "service": [ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#service-1", - "type": "harbour:TrustAnchorService", + "type": "TrustAnchorService", "didcore:serviceEndpoint": { "type": "schema:Organization", "name": "Haven Trust Anchor", @@ -56,9 +56,9 @@ }, { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#service-2", - "type": "harbour:CRSetRevocationRegistryService", + "type": "CRSetRevocationRegistryService", "didcore:serviceEndpoint": { - "type": "harbour:CRSetServiceEndpoint", + "type": "CRSetServiceEndpoint", "registryEndpoint": "https://resolver.harbour.id/crset/" } } diff --git a/examples/did-ethr/harbour-trust-anchor.did.json b/examples/did-ethr/harbour-trust-anchor.did.json index a7429cf..5dc56b1 100644 --- a/examples/did-ethr/harbour-trust-anchor.did.json +++ b/examples/did-ethr/harbour-trust-anchor.did.json @@ -33,7 +33,7 @@ "service": [ { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774#service-1", - "type": "harbour:LinkedCredentialService", + "type": "LinkedCredentialService", "serviceEndpoint": "https://reachhaven.com/.well-known/credentials/trust-anchor.jwt" } ] diff --git a/examples/gaiax/delegated-signing-receipt.json b/examples/gaiax/delegated-signing-receipt.json index a16b92a..1fffd13 100644 --- a/examples/gaiax/delegated-signing-receipt.json +++ b/examples/gaiax/delegated-signing-receipt.json @@ -2,7 +2,8 @@ "@context": [ "https://www.w3.org/ns/credentials/v2", "https://w3id.org/reachhaven/harbour/core/v1/", - "https://w3id.org/reachhaven/harbour/gx/v1/" + "https://w3id.org/reachhaven/harbour/gx/v1/", + "https://w3id.org/reachhaven/harbour/delegate/v1/" ], "type": [ "VerifiableCredential", @@ -13,22 +14,22 @@ "validFrom": "2025-06-25T10:00:00Z", "credentialSubject": { "id": "urn:uuid:receipt-b7c8d9e0-f1a2-3456-789a-bcdef0123456", - "type": "harbour:TransactionReceipt", + "type": "TransactionReceipt", "transactionHash": "c3d4ba771c1103935ab4121874c4b3a78c8471719c80f60d59ca5811e232089b", "blockchainTxId": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" }, "credentialStatus": [ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/f7e8d9c0b1a23456", - "type": "harbour:CRSetEntry", + "type": "CRSetEntry", "statusPurpose": "revocation" } ], "evidence": [ { "type": [ - "harbour:Evidence", - "harbour:DelegatedSignatureEvidence" + "Evidence", + "DelegatedSignatureEvidence" ], "verifiablePresentation": { "@context": [ @@ -36,7 +37,7 @@ ], "type": [ "VerifiablePresentation", - "harbour:VerifiablePresentation" + "HarbourVerifiablePresentation" ], "holder": "did:jwk:eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6IjZka1U2Wk1GSzc5V3dpY3dKNXJieEUxM3pTdWtCWTJPb0VpVlVFanFNRWMiLCJ5IjoiUm5Iem55VmxyUFNNVDdpckRzMTVEOXd4Z01vamlTREFRcGZGaHFUa0xSWSJ9", "verifiableCredential": [ diff --git a/examples/gaiax/legal-person-credential-embedded.json b/examples/gaiax/legal-person-credential-embedded.json index a83f0a9..e07cd7f 100644 --- a/examples/gaiax/legal-person-credential-embedded.json +++ b/examples/gaiax/legal-person-credential-embedded.json @@ -47,15 +47,15 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/e1e2e3e4e5e6e7e8", - "type": "harbour:CRSetEntry", + "type": "CRSetEntry", "statusPurpose": "revocation" } ], "evidence": [ { "type": [ - "harbour:Evidence", - "harbour:CredentialEvidence" + "Evidence", + "CredentialEvidence" ], "verifiablePresentation": { "@context": [ @@ -63,7 +63,7 @@ ], "type": [ "VerifiablePresentation", - "harbour:VerifiablePresentation" + "HarbourVerifiablePresentation" ], "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "verifiableCredential": [ @@ -79,7 +79,7 @@ "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", - "type": "harbour:LinkedCredentialService", + "type": "LinkedCredentialService", "didcore:serviceEndpoint": { "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" } diff --git a/examples/gaiax/legal-person-credential.json b/examples/gaiax/legal-person-credential.json index 74cb72f..87bb5b6 100644 --- a/examples/gaiax/legal-person-credential.json +++ b/examples/gaiax/legal-person-credential.json @@ -44,15 +44,15 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/a1b2c3d4e5f67890", - "type": "harbour:CRSetEntry", + "type": "CRSetEntry", "statusPurpose": "revocation" } ], "evidence": [ { "type": [ - "harbour:Evidence", - "harbour:CredentialEvidence" + "Evidence", + "CredentialEvidence" ], "verifiablePresentation": { "@context": [ @@ -60,7 +60,7 @@ ], "type": [ "VerifiablePresentation", - "harbour:VerifiablePresentation" + "HarbourVerifiablePresentation" ], "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "verifiableCredential": [ @@ -76,7 +76,7 @@ "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", - "type": "harbour:LinkedCredentialService", + "type": "LinkedCredentialService", "didcore:serviceEndpoint": { "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" } diff --git a/examples/gaiax/natural-person-credential.json b/examples/gaiax/natural-person-credential.json index 02f30bd..8b1259c 100644 --- a/examples/gaiax/natural-person-credential.json +++ b/examples/gaiax/natural-person-credential.json @@ -30,15 +30,15 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/b2c3d4e5f6a78901", - "type": "harbour:CRSetEntry", + "type": "CRSetEntry", "statusPurpose": "revocation" } ], "evidence": [ { "type": [ - "harbour:Evidence", - "harbour:CredentialEvidence" + "Evidence", + "CredentialEvidence" ], "verifiablePresentation": { "@context": [ @@ -49,7 +49,7 @@ ], "type": [ "VerifiablePresentation", - "harbour:VerifiablePresentation" + "HarbourVerifiablePresentation" ], "holder": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", "verifiableCredential": [ @@ -97,15 +97,15 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/a1b2c3d4e5f67899", - "type": "harbour:CRSetEntry", + "type": "CRSetEntry", "statusPurpose": "revocation" } ], "evidence": [ { "type": [ - "harbour:Evidence", - "harbour:CredentialEvidence" + "Evidence", + "CredentialEvidence" ], "verifiablePresentation": { "@context": [ @@ -114,7 +114,7 @@ ], "type": [ "VerifiablePresentation", - "harbour:VerifiablePresentation" + "HarbourVerifiablePresentation" ], "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "verifiableCredential": [ @@ -130,7 +130,7 @@ "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", - "type": "harbour:LinkedCredentialService", + "type": "LinkedCredentialService", "didcore:serviceEndpoint": { "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" } diff --git a/examples/gaiax/participant-vp.json b/examples/gaiax/participant-vp.json index e1a2670..f9af474 100644 --- a/examples/gaiax/participant-vp.json +++ b/examples/gaiax/participant-vp.json @@ -7,7 +7,7 @@ ], "type": [ "VerifiablePresentation", - "harbour:VerifiablePresentation" + "HarbourVerifiablePresentation" ], "holder": "did:ethr:0x14a34:0xd1d2d3d4d5d6d7d8d9d0d1d2d3d4d5d6d7d8d9d0", "verifiableCredential": [ @@ -140,15 +140,15 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/a1b2c3d4e5f67890", - "type": "harbour:CRSetEntry", + "type": "CRSetEntry", "statusPurpose": "revocation" } ], "evidence": [ { "type": [ - "harbour:Evidence", - "harbour:CredentialEvidence" + "Evidence", + "CredentialEvidence" ], "verifiablePresentation": { "@context": [ @@ -156,7 +156,7 @@ ], "type": [ "VerifiablePresentation", - "harbour:VerifiablePresentation" + "HarbourVerifiablePresentation" ], "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "verifiableCredential": [ @@ -172,7 +172,7 @@ "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", - "type": "harbour:LinkedCredentialService", + "type": "LinkedCredentialService", "didcore:serviceEndpoint": { "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" } diff --git a/examples/gaiax/trust-anchor-credential.json b/examples/gaiax/trust-anchor-credential.json index 234eccf..f4ababe 100644 --- a/examples/gaiax/trust-anchor-credential.json +++ b/examples/gaiax/trust-anchor-credential.json @@ -5,20 +5,20 @@ ], "type": [ "VerifiableCredential", - "harbour:VerifiableCredential" + "HarbourVerifiableCredential" ], "id": "urn:uuid:c4d5e6f7-a8b9-0123-cdef-456789abcdef", "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", - "type": "harbour:LinkedCredentialService", + "type": "LinkedCredentialService", "didcore:serviceEndpoint": {"id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774"} }, "credentialStatus": [ { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774:services:revocation-registry/c4d5e6f7a8b90123", - "type": "harbour:CRSetEntry", + "type": "CRSetEntry", "statusPurpose": "revocation" } ] diff --git a/linkml/harbour-core-credential.yaml b/linkml/harbour-core-credential.yaml index 390670d..f509967 100644 --- a/linkml/harbour-core-credential.yaml +++ b/linkml/harbour-core-credential.yaml @@ -71,6 +71,7 @@ prefixes: cs: https://www.w3.org/ns/credentials/status# cred: https://www.w3.org/2018/credentials# didcore: https://www.w3.org/ns/did# + rdfs: http://www.w3.org/2000/01/rdf-schema# default_prefix: harbour default_range: string @@ -205,7 +206,7 @@ slots: shown as expanded JSON-LD for readability; on the wire it is encoded as a VC-JOSE-COSE compact JWS string (typ: vp+jwt) or SD-JWT VP. slot_uri: harbour:verifiablePresentation - range: VerifiablePresentation + range: HarbourVerifiablePresentation required: false # --- Delegated Signature Evidence Slots --- @@ -315,6 +316,13 @@ classes: description: A human-readable name for the organization. slot_uri: sdo:name range: string + label: + description: >- + A human-readable label. Entailed via RDFS inference from schema:name + (schema.org declares schema:name rdfs:subPropertyOf rdfs:label). + Declared so sh:closed SHACL shapes accept the inferred property. + slot_uri: rdfs:label + range: string url: description: A URL associated with the organization. slot_uri: sdo:url @@ -413,7 +421,7 @@ classes: required: true description: Status entries for revocation checking (CRSet). - VerifiableCredential: + HarbourVerifiableCredential: is_a: HarbourCredential description: > Concrete credential type at the core layer. Validates only the @@ -421,6 +429,10 @@ classes: No domain-specific credentialSubject requirements — any subject is valid. Domain layers (e.g. harbour-gx-credential) define specialized credential types with typed subjects. + Named 'HarbourVerifiableCredential' (not 'VerifiableCredential') to + avoid JSON-LD @protected term collision with W3C VC v2 context, + which defines 'VerifiableCredential' as a protected term mapping to + https://www.w3.org/2018/credentials#VerifiableCredential. class_uri: harbour:VerifiableCredential annotations: vct: "https://w3id.org/reachhaven/harbour/core/v1/VerifiableCredential" @@ -453,13 +465,15 @@ classes: One or more verifiable credentials being presented. On the wire each entry is a compact JWS string (vc+jwt) or SD-JWT-VC token. - VerifiablePresentation: + HarbourVerifiablePresentation: is_a: HarbourPresentation description: > Concrete presentation type at the core layer. Wraps one or more harbour:VerifiableCredential instances for transmission. Evidence VPs embedded in credentials use this type. Domain layers may define specialized presentation types if needed. + Named 'HarbourVerifiablePresentation' (not 'VerifiablePresentation') + to avoid JSON-LD @protected term collision with W3C VC v2 context. class_uri: harbour:VerifiablePresentation annotations: vpt: "https://w3id.org/reachhaven/harbour/core/v1/VerifiablePresentation" diff --git a/pyproject.toml b/pyproject.toml index d1d6a90..4de787e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "joserfc>=1.0.0", "cryptography>=44.0.0", "base58>=2.1.0", + "pycryptodome>=3.20", ] [project.optional-dependencies] diff --git a/src/python/harbour/generate_artifacts.py b/src/python/harbour/generate_artifacts.py index 05cd0f0..c9ae815 100644 --- a/src/python/harbour/generate_artifacts.py +++ b/src/python/harbour/generate_artifacts.py @@ -11,6 +11,11 @@ The ``xsd_anyuri_as_iri=True`` flag (ASCS-eV/linkml PR #3292) ensures ``range: uri`` slots produce ``@type: @id`` in the context, matching the SHACL ``sh:nodeKind sh:IRI`` constraint. + +The ``normalize_prefixes=True`` flag (ASCS-eV/linkml PR #3308) maps +non-standard prefix aliases (e.g. ``sdo`` → ``schema``, ``dce`` → ``dc``) +to their canonical well-known names, producing cleaner and more portable +artifacts. """ import json @@ -64,6 +69,7 @@ def main() -> None: owl_gen = OwlSchemaGenerator( schema, mergeimports=False, deterministic=True, + normalize_prefixes=True, importmap=importmap, base_dir=base_dir ) owl_text = owl_gen.serialize() @@ -81,6 +87,7 @@ def main() -> None: if domain not in SHACL_SKIP_DOMAINS: shacl_gen = ShaclGenerator( schema, deterministic=True, + normalize_prefixes=True, importmap=importmap, base_dir=base_dir, ) (out_dir / f"{domain}.shacl.ttl").write_text( @@ -92,6 +99,7 @@ def main() -> None: mergeimports=False, exclude_external_imports=True, xsd_anyuri_as_iri=True, + normalize_prefixes=True, deterministic=True, importmap=importmap, base_dir=base_dir, diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index 941c42d..62135fe 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit 941c42d86048717b4890ffa981a2ddfe107d2266 +Subproject commit 62135fe7adb7193961dae87e75bae5c80c1c1f01 diff --git a/tests/interop/test_cross_runtime.py b/tests/interop/test_cross_runtime.py index 937eb33..7ce4a9c 100644 --- a/tests/interop/test_cross_runtime.py +++ b/tests/interop/test_cross_runtime.py @@ -23,14 +23,17 @@ def _can_run_node_jose() -> bool: """Check whether yarn-managed Node can import jose in the TS workspace.""" - result = subprocess.run( - ["yarn", "node", "--input-type=module", "-e", 'import "jose";'], - capture_output=True, - text=True, - cwd=str(TS_DIR), - timeout=30, - ) - return result.returncode == 0 + try: + result = subprocess.run( + ["yarn", "node", "--input-type=module", "-e", 'import "jose";'], + capture_output=True, + text=True, + cwd=str(TS_DIR), + timeout=30, + ) + return result.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + return False # Skip if TypeScript runtime dependencies are unavailable From 62f74b84057f1b4d1af5c5a536329779d3d955b9 Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 27 Mar 2026 17:27:34 +0100 Subject: [PATCH 44/78] chore: bump ontology-management-base submodule Pick up GX overProvisioningRatio type fix (float->decimal) and ODRL prefix normalization. Signed-off-by: jdsika --- submodules/ontology-management-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index 62135fe..6ead02c 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit 62135fe7adb7193961dae87e75bae5c80c1c1f01 +Subproject commit 6ead02cd4185b73d773aa493f8ec1b9d09133a0b From debd9e976ebf069bca6da9436e268f7119b2a324 Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 27 Mar 2026 18:27:26 +0100 Subject: [PATCH 45/78] fix(tests): update assertions for bare term prefix simplification Update test_validation.py and test_example_signer.py to reflect the prefix simplification where harbour: prefixed types were replaced with bare context terms (CRSetEntry, DelegatedSignatureEvidence, TransactionReceipt, LinkedCredentialService). Accept both prefixed and bare subject types in credential validation. Signed-off-by: jdsika --- tests/python/credentials/test_example_signer.py | 4 ++-- tests/python/credentials/test_validation.py | 17 +++++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/python/credentials/test_example_signer.py b/tests/python/credentials/test_example_signer.py index 24904a2..82b8b79 100644 --- a/tests/python/credentials/test_example_signer.py +++ b/tests/python/credentials/test_example_signer.py @@ -186,9 +186,9 @@ def test_process_delegated_signing_receipt(self, signing_key, tmp_path): evidence = vc_payload["evidence"][0] ev_type = evidence["type"] if isinstance(ev_type, list): - assert "harbour:DelegatedSignatureEvidence" in ev_type + assert "DelegatedSignatureEvidence" in ev_type else: - assert ev_type == "harbour:DelegatedSignatureEvidence" + assert ev_type == "DelegatedSignatureEvidence" assert "transaction_data" in evidence assert evidence["transaction_data"]["type"] == "harbour.delegate:data.purchase" assert ( diff --git a/tests/python/credentials/test_validation.py b/tests/python/credentials/test_validation.py index d7b08ca..9d1a6bb 100644 --- a/tests/python/credentials/test_validation.py +++ b/tests/python/credentials/test_validation.py @@ -117,7 +117,7 @@ def test_has_credential_status(credential_file): f"Missing credentialStatus in {credential_file.name}" ) for entry in status: - assert entry.get("type") == "harbour:CRSetEntry" + assert entry.get("type") == "CRSetEntry" assert "statusPurpose" in entry @@ -133,15 +133,20 @@ def test_credential_subject_has_type(credential_file): # Gaia-X compliance credentials (TermsAndConditions, RegistrationNumber, # Compliance) use gx: types directly on credentialSubject because the gx # SHACL shapes are sh:closed true — harbour wrappers would add properties - # that violate the closed shape constraint. All other domain credentials - # use harbour: or harbour.gx: prefixed types. + # that violate the closed shape constraint. Other domain credentials use + # bare terms from the harbour context (e.g. TransactionReceipt, + # LinkedCredentialService) or harbour:/gx: prefixed types. allowed_prefixes = ("harbour:", "harbour.gx:", "gx:") + + def _is_valid_type(t: str) -> bool: + return t.startswith(allowed_prefixes) or ":" not in t + if isinstance(subject_type, str): - assert subject_type.startswith(allowed_prefixes), ( - f"Subject type should be harbour- or gx-prefixed, got: {subject_type}" + assert _is_valid_type(subject_type), ( + f"Subject type should be a harbour/gx type, got: {subject_type}" ) elif isinstance(subject_type, list): - assert any(t.startswith(allowed_prefixes) for t in subject_type), ( + assert any(_is_valid_type(t) for t in subject_type), ( f"Subject type list should include a harbour or gx type: {subject_type}" ) From 52b9e3c4413017b71e890109e1266960f145aaf5 Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 27 Mar 2026 18:32:28 +0100 Subject: [PATCH 46/78] style: apply ruff formatting fixes Signed-off-by: jdsika --- src/python/harbour/generate_artifacts.py | 15 ++++++++++----- src/python/harbour/sd_jwt.py | 8 ++++++-- tests/python/harbour/test_sd_jwt.py | 8 ++++++-- 3 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/python/harbour/generate_artifacts.py b/src/python/harbour/generate_artifacts.py index c9ae815..0fe921c 100644 --- a/src/python/harbour/generate_artifacts.py +++ b/src/python/harbour/generate_artifacts.py @@ -24,7 +24,7 @@ from linkml.generators.jsonldcontextgen import ContextGenerator from linkml.generators.owlgen import OwlSchemaGenerator from linkml.generators.shaclgen import ShaclGenerator -from rdflib import RDFS, OWL, URIRef +from rdflib import OWL, RDFS, URIRef REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent LINKML_DIR = REPO_ROOT / "linkml" @@ -68,9 +68,12 @@ def main() -> None: print(f" Processing {domain}...") owl_gen = OwlSchemaGenerator( - schema, mergeimports=False, deterministic=True, + schema, + mergeimports=False, + deterministic=True, normalize_prefixes=True, - importmap=importmap, base_dir=base_dir + importmap=importmap, + base_dir=base_dir, ) owl_text = owl_gen.serialize() @@ -86,9 +89,11 @@ def main() -> None: if domain not in SHACL_SKIP_DOMAINS: shacl_gen = ShaclGenerator( - schema, deterministic=True, + schema, + deterministic=True, normalize_prefixes=True, - importmap=importmap, base_dir=base_dir, + importmap=importmap, + base_dir=base_dir, ) (out_dir / f"{domain}.shacl.ttl").write_text( shacl_gen.serialize(), encoding="utf-8" diff --git a/src/python/harbour/sd_jwt.py b/src/python/harbour/sd_jwt.py index f0a1191..d99cfba 100644 --- a/src/python/harbour/sd_jwt.py +++ b/src/python/harbour/sd_jwt.py @@ -177,7 +177,9 @@ def _collect_sd_digests(obj: Any) -> set[str]: return digests -def _insert_disclosure_recursive(obj: dict, claim_name: str, claim_value: Any, digest: str) -> bool: +def _insert_disclosure_recursive( + obj: dict, claim_name: str, claim_value: Any, digest: str +) -> bool: """Recursively find the _sd array containing this digest and insert the claim. Returns True if the digest was found and the claim was inserted. @@ -292,7 +294,9 @@ def verify_sd_jwt_vc( _, claim_name, claim_value = disc_array # Insert the claim at the correct nesting level - if not _insert_disclosure_recursive(payload, claim_name, claim_value, disc_hash): + if not _insert_disclosure_recursive( + payload, claim_name, claim_value, disc_hash + ): raise VerificationError( f"Could not locate _sd digest for claim {claim_name!r}" ) diff --git a/tests/python/harbour/test_sd_jwt.py b/tests/python/harbour/test_sd_jwt.py index 6f2d835..a0196b3 100644 --- a/tests/python/harbour/test_sd_jwt.py +++ b/tests/python/harbour/test_sd_jwt.py @@ -300,11 +300,15 @@ def test_nested_disclosure_preserves_always_disclosed( # All non-disclosable claims preserved cs = result["credentialSubject"] assert cs["id"] == "did:ethr:0x14a34:0x9d273DCaC2f6367968d61caf69A7E3177fd81048" - assert cs["harbourCredential"] == "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890" + assert ( + cs["harbourCredential"] == "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ) assert cs["legalForm"] == "AG" assert cs["duns"] == "313995269" assert cs["url"] == "https://www.bmwgroup.com/" - assert cs["gxParticipant"]["name"] == "Bayerische Motoren Werke Aktiengesellschaft" + assert ( + cs["gxParticipant"]["name"] == "Bayerische Motoren Werke Aktiengesellschaft" + ) # Email should NOT be present (disclosure was removed) assert "email" not in cs From 63c0e6dddf5d6d07bf0f4ef4919ee34ad940c21d Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 27 Mar 2026 19:08:12 +0100 Subject: [PATCH 47/78] chore: update OMB submodule pin Includes: - fix: migrate sdo: prefix to schema: in GX legalperson fixture - chore: update linkml submodule pin Signed-off-by: jdsika --- submodules/ontology-management-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index 6ead02c..40f45c5 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit 6ead02cd4185b73d773aa493f8ec1b9d09133a0b +Subproject commit 40f45c57279badb02136765f3226cdd2ad3c1ec1 From b53d7d8818baf7789605277e71e385753a167548 Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 27 Mar 2026 19:43:20 +0100 Subject: [PATCH 48/78] fix(linkml): drop class_uri overrides for HarbourVerifiable* types Remove class_uri: harbour:VerifiablePresentation and class_uri: harbour:VerifiableCredential from the LinkML schema. Without the override, LinkML uses the default @vocab + ClassName IRI (harbour:HarbourVerifiablePresentation), which does not collide with the W3C VC v2 @protected terms (cred:VerifiablePresentation). The generated context emits plain bare terms that resolve unambiguously via the harbour @vocab. This eliminates the need for post-processing hacks in generate_artifacts.py and fixes the NaturalPersonCredential SHACL validation failure where the evidence VP type was misresolved. Signed-off-by: jdsika --- linkml/harbour-core-credential.yaml | 8 +++----- src/python/harbour/generate_artifacts.py | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/linkml/harbour-core-credential.yaml b/linkml/harbour-core-credential.yaml index f509967..1f58a1c 100644 --- a/linkml/harbour-core-credential.yaml +++ b/linkml/harbour-core-credential.yaml @@ -433,9 +433,8 @@ classes: avoid JSON-LD @protected term collision with W3C VC v2 context, which defines 'VerifiableCredential' as a protected term mapping to https://www.w3.org/2018/credentials#VerifiableCredential. - class_uri: harbour:VerifiableCredential annotations: - vct: "https://w3id.org/reachhaven/harbour/core/v1/VerifiableCredential" + vct: "https://w3id.org/reachhaven/harbour/core/v1/HarbourVerifiableCredential" # ========================================== # 3b. PRESENTATION TYPES @@ -469,14 +468,13 @@ classes: is_a: HarbourPresentation description: > Concrete presentation type at the core layer. Wraps one or more - harbour:VerifiableCredential instances for transmission. Evidence + HarbourVerifiableCredential instances for transmission. Evidence VPs embedded in credentials use this type. Domain layers may define specialized presentation types if needed. Named 'HarbourVerifiablePresentation' (not 'VerifiablePresentation') to avoid JSON-LD @protected term collision with W3C VC v2 context. - class_uri: harbour:VerifiablePresentation annotations: - vpt: "https://w3id.org/reachhaven/harbour/core/v1/VerifiablePresentation" + vpt: "https://w3id.org/reachhaven/harbour/core/v1/HarbourVerifiablePresentation" # ========================================== # 4. EVIDENCE TYPES diff --git a/src/python/harbour/generate_artifacts.py b/src/python/harbour/generate_artifacts.py index 0fe921c..ba20e02 100644 --- a/src/python/harbour/generate_artifacts.py +++ b/src/python/harbour/generate_artifacts.py @@ -117,8 +117,8 @@ def main() -> None: ctx_obj = ctx_data.get("@context", {}) if isinstance(ctx_obj, dict) and "type" not in ctx_obj: ctx_obj["type"] = "@type" - ctx_data["@context"] = ctx_obj + ctx_data["@context"] = ctx_obj ctx_text = json.dumps(ctx_data, indent=3, ensure_ascii=False) (out_dir / f"{domain}.context.jsonld").write_text(ctx_text, encoding="utf-8") From 6ed417b104502e57b7a601d6c7c01c7d8cfd8ec9 Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 27 Mar 2026 21:02:18 +0100 Subject: [PATCH 49/78] fix(examples): add HarbourVerifiableCredential type to inner evidence VCs Inner LinkedCredentialService VCs inside evidence VPs only had type ['VerifiableCredential'] but should also include 'HarbourVerifiableCredential' since they are harbour-issued. Add the harbour core context URL to their @context arrays so the HarbourVerifiableCredential term resolves correctly. External Gaia-X VCs (gx-legal-person, gx-registration-number, gx-terms-and-conditions, participant-vp outer entries) are left unchanged as they are not harbour credentials. Signed-off-by: jdsika --- examples/credential-with-evidence.json | 12 ++++++++++-- examples/credential-with-nested-evidence.json | 12 ++++++++++-- examples/gaiax/delegated-signing-receipt.json | 12 ++++++++++-- examples/gaiax/legal-person-credential-embedded.json | 12 ++++++++++-- examples/gaiax/legal-person-credential.json | 12 ++++++++++-- examples/gaiax/natural-person-credential.json | 12 ++++++++++-- examples/gaiax/participant-vp.json | 12 ++++++++++-- 7 files changed, 70 insertions(+), 14 deletions(-) diff --git a/examples/credential-with-evidence.json b/examples/credential-with-evidence.json index 69b8ed0..2a7b385 100644 --- a/examples/credential-with-evidence.json +++ b/examples/credential-with-evidence.json @@ -43,7 +43,8 @@ "https://w3id.org/reachhaven/harbour/core/v1/" ], "type": [ - "VerifiableCredential" + "VerifiableCredential", + "HarbourVerifiableCredential" ], "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2024-01-01T00:00:00Z", @@ -53,7 +54,14 @@ "didcore:serviceEndpoint": { "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" } - } + }, + "credentialStatus": [ + { + "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774:services:revocation-registry/lcs00000001", + "type": "CRSetEntry", + "statusPurpose": "revocation" + } + ] } ] } diff --git a/examples/credential-with-nested-evidence.json b/examples/credential-with-nested-evidence.json index 1974a03..a20ee76 100644 --- a/examples/credential-with-nested-evidence.json +++ b/examples/credential-with-nested-evidence.json @@ -43,7 +43,8 @@ "https://w3id.org/reachhaven/harbour/core/v1/" ], "type": [ - "VerifiableCredential" + "VerifiableCredential", + "HarbourVerifiableCredential" ], "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "validFrom": "2024-01-01T00:00:00Z", @@ -53,7 +54,14 @@ "didcore:serviceEndpoint": { "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" } - } + }, + "credentialStatus": [ + { + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/lcs00000002", + "type": "CRSetEntry", + "statusPurpose": "revocation" + } + ] } ] } diff --git a/examples/gaiax/delegated-signing-receipt.json b/examples/gaiax/delegated-signing-receipt.json index 1fffd13..91c948f 100644 --- a/examples/gaiax/delegated-signing-receipt.json +++ b/examples/gaiax/delegated-signing-receipt.json @@ -48,7 +48,8 @@ "https://w3id.org/reachhaven/harbour/gx/v1/" ], "type": [ - "VerifiableCredential" + "VerifiableCredential", + "HarbourVerifiableCredential" ], "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "validFrom": "2024-01-15T00:00:00Z", @@ -56,7 +57,14 @@ "id": "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129", "type": "harbour.gx:NaturalPerson", "memberOf": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93" - } + }, + "credentialStatus": [ + { + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/np00000001", + "type": "CRSetEntry", + "statusPurpose": "revocation" + } + ] } ] }, diff --git a/examples/gaiax/legal-person-credential-embedded.json b/examples/gaiax/legal-person-credential-embedded.json index e07cd7f..acd9f85 100644 --- a/examples/gaiax/legal-person-credential-embedded.json +++ b/examples/gaiax/legal-person-credential-embedded.json @@ -73,7 +73,8 @@ "https://w3id.org/reachhaven/harbour/core/v1/" ], "type": [ - "VerifiableCredential" + "VerifiableCredential", + "HarbourVerifiableCredential" ], "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2024-01-01T00:00:00Z", @@ -83,7 +84,14 @@ "didcore:serviceEndpoint": { "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" } - } + }, + "credentialStatus": [ + { + "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774:services:revocation-registry/lcs00000001", + "type": "CRSetEntry", + "statusPurpose": "revocation" + } + ] } ] } diff --git a/examples/gaiax/legal-person-credential.json b/examples/gaiax/legal-person-credential.json index 87bb5b6..81c406a 100644 --- a/examples/gaiax/legal-person-credential.json +++ b/examples/gaiax/legal-person-credential.json @@ -70,7 +70,8 @@ "https://w3id.org/reachhaven/harbour/core/v1/" ], "type": [ - "VerifiableCredential" + "VerifiableCredential", + "HarbourVerifiableCredential" ], "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2024-01-01T00:00:00Z", @@ -80,7 +81,14 @@ "didcore:serviceEndpoint": { "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" } - } + }, + "credentialStatus": [ + { + "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774:services:revocation-registry/lcs00000001", + "type": "CRSetEntry", + "statusPurpose": "revocation" + } + ] } ] } diff --git a/examples/gaiax/natural-person-credential.json b/examples/gaiax/natural-person-credential.json index 8b1259c..9b1f706 100644 --- a/examples/gaiax/natural-person-credential.json +++ b/examples/gaiax/natural-person-credential.json @@ -124,7 +124,8 @@ "https://w3id.org/reachhaven/harbour/core/v1/" ], "type": [ - "VerifiableCredential" + "VerifiableCredential", + "HarbourVerifiableCredential" ], "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2024-01-01T00:00:00Z", @@ -134,7 +135,14 @@ "didcore:serviceEndpoint": { "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" } - } + }, + "credentialStatus": [ + { + "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774:services:revocation-registry/lcs00000001", + "type": "CRSetEntry", + "statusPurpose": "revocation" + } + ] } ] } diff --git a/examples/gaiax/participant-vp.json b/examples/gaiax/participant-vp.json index f9af474..ae10fbb 100644 --- a/examples/gaiax/participant-vp.json +++ b/examples/gaiax/participant-vp.json @@ -166,7 +166,8 @@ "https://w3id.org/reachhaven/harbour/core/v1/" ], "type": [ - "VerifiableCredential" + "VerifiableCredential", + "HarbourVerifiableCredential" ], "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2024-01-01T00:00:00Z", @@ -176,7 +177,14 @@ "didcore:serviceEndpoint": { "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" } - } + }, + "credentialStatus": [ + { + "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774:services:revocation-registry/lcs00000001", + "type": "CRSetEntry", + "statusPurpose": "revocation" + } + ] } ] } From 094a655a0e52a3467a6de880387b1c3c8fbe6377 Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 27 Mar 2026 21:15:20 +0100 Subject: [PATCH 50/78] fix(examples): rework nested evidence example to show actual 2-level nesting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous credential-with-nested-evidence.json was structurally identical to credential-with-evidence.json (only placeholder IDs differed). Rework it to demonstrate genuine nested evidence: outer VC → evidence VP → inner VC → evidence VP → LinkedCredentialService VC The inner VC at level 1 now carries its own evidence array containing a second VP with a LinkedCredentialService credential, showing the recursive evidence pattern used in the harbour trust chain. Signed-off-by: jdsika --- examples/credential-with-nested-evidence.json | 66 ++++++++++++++++--- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/examples/credential-with-nested-evidence.json b/examples/credential-with-nested-evidence.json index a20ee76..9c3443d 100644 --- a/examples/credential-with-nested-evidence.json +++ b/examples/credential-with-nested-evidence.json @@ -1,4 +1,5 @@ { + "_comment": "Demonstrates 2-level nested evidence: outer VC → evidence VP → inner VC → evidence VP → LinkedCredentialService VC", "@context": [ "https://www.w3.org/ns/credentials/v2", "https://w3id.org/reachhaven/harbour/core/v1/" @@ -29,7 +30,8 @@ ], "verifiablePresentation": { "@context": [ - "https://www.w3.org/ns/credentials/v2" + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/core/v1/" ], "type": [ "VerifiablePresentation", @@ -38,6 +40,7 @@ "holder": "did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "verifiableCredential": [ { + "_comment": "Level-1 inner VC — itself carries evidence (the nesting)", "@context": [ "https://www.w3.org/ns/credentials/v2", "https://w3id.org/reachhaven/harbour/core/v1/" @@ -46,21 +49,66 @@ "VerifiableCredential", "HarbourVerifiableCredential" ], - "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", - "validFrom": "2024-01-01T00:00:00Z", + "id": "urn:uuid:33333333-3333-3333-3333-333333333333", + "issuer": "did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + "validFrom": "2024-01-10T00:00:00Z", "credentialSubject": { - "id": "did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", - "type": "LinkedCredentialService", - "didcore:serviceEndpoint": { - "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3" - } + "id": "did:ethr:0x14a34:0xd4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4" }, "credentialStatus": [ { - "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/lcs00000002", + "id": "did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3:services:revocation-registry/c3d4e5f6a7b89012", "type": "CRSetEntry", "statusPurpose": "revocation" } + ], + "evidence": [ + { + "type": [ + "Evidence", + "CredentialEvidence" + ], + "verifiablePresentation": { + "_comment": "Level-2 evidence VP inside the inner VC", + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/core/v1/" + ], + "type": [ + "VerifiablePresentation", + "HarbourVerifiablePresentation" + ], + "holder": "did:ethr:0x14a34:0xe5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/core/v1/" + ], + "type": [ + "VerifiableCredential", + "HarbourVerifiableCredential" + ], + "issuer": "did:ethr:0x14a34:0xe5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "id": "did:ethr:0x14a34:0xe5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", + "type": "LinkedCredentialService", + "didcore:serviceEndpoint": { + "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xe5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5" + } + }, + "credentialStatus": [ + { + "id": "did:ethr:0x14a34:0xe5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5:services:revocation-registry/lcs00000003", + "type": "CRSetEntry", + "statusPurpose": "revocation" + } + ] + } + ] + } + } ] } ] From b149859d7d94f2a919c7b103219a6240bd72b275 Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 27 Mar 2026 21:26:24 +0100 Subject: [PATCH 51/78] fix(examples): add HarbourVerifiableCredential to all harbour-issued VCs Audit found 5 harbour-issued VCs missing HarbourVerifiableCredential in their type arrays. All harbour VCs must include this base type alongside their domain-specific type (e.g. harbour.gx:LegalPersonCredential). Fixed files: - delegated-signing-receipt.json (outer VC) - legal-person-credential-embedded.json (outer VC) - legal-person-credential.json (outer VC) - natural-person-credential.json (outer VC + inner LegalPersonCredential) - participant-vp.json (LegalPersonCredential entry) External Gaia-X VCs (gx-legal-person, gx-registration-number, gx-terms-and-conditions) correctly left without this type. Signed-off-by: jdsika --- examples/gaiax/delegated-signing-receipt.json | 1 + examples/gaiax/legal-person-credential-embedded.json | 1 + examples/gaiax/legal-person-credential.json | 1 + examples/gaiax/natural-person-credential.json | 2 ++ examples/gaiax/participant-vp.json | 1 + 5 files changed, 6 insertions(+) diff --git a/examples/gaiax/delegated-signing-receipt.json b/examples/gaiax/delegated-signing-receipt.json index 91c948f..4146c96 100644 --- a/examples/gaiax/delegated-signing-receipt.json +++ b/examples/gaiax/delegated-signing-receipt.json @@ -7,6 +7,7 @@ ], "type": [ "VerifiableCredential", + "HarbourVerifiableCredential", "harbour:DelegatedSigningReceipt" ], "id": "urn:uuid:f7e8d9c0-b1a2-3456-7890-abcdef012345", diff --git a/examples/gaiax/legal-person-credential-embedded.json b/examples/gaiax/legal-person-credential-embedded.json index acd9f85..5cb4d3f 100644 --- a/examples/gaiax/legal-person-credential-embedded.json +++ b/examples/gaiax/legal-person-credential-embedded.json @@ -7,6 +7,7 @@ ], "type": [ "VerifiableCredential", + "HarbourVerifiableCredential", "harbour.gx:LegalPersonCredential" ], "id": "urn:uuid:e1e2e3e4-e5e6-e7e8-e9e0-e1e2e3e4e5e6", diff --git a/examples/gaiax/legal-person-credential.json b/examples/gaiax/legal-person-credential.json index 81c406a..95053e7 100644 --- a/examples/gaiax/legal-person-credential.json +++ b/examples/gaiax/legal-person-credential.json @@ -7,6 +7,7 @@ ], "type": [ "VerifiableCredential", + "HarbourVerifiableCredential", "harbour.gx:LegalPersonCredential" ], "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", diff --git a/examples/gaiax/natural-person-credential.json b/examples/gaiax/natural-person-credential.json index 9b1f706..2542f62 100644 --- a/examples/gaiax/natural-person-credential.json +++ b/examples/gaiax/natural-person-credential.json @@ -7,6 +7,7 @@ ], "type": [ "VerifiableCredential", + "HarbourVerifiableCredential", "harbour.gx:NaturalPersonCredential" ], "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f23456789012", @@ -62,6 +63,7 @@ ], "type": [ "VerifiableCredential", + "HarbourVerifiableCredential", "harbour.gx:LegalPersonCredential" ], "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567899", diff --git a/examples/gaiax/participant-vp.json b/examples/gaiax/participant-vp.json index ae10fbb..aa11826 100644 --- a/examples/gaiax/participant-vp.json +++ b/examples/gaiax/participant-vp.json @@ -103,6 +103,7 @@ ], "type": [ "VerifiableCredential", + "HarbourVerifiableCredential", "harbour.gx:LegalPersonCredential" ], "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", From a688619682ac04dd50df0f4cebec07150e78dd99 Mon Sep 17 00:00:00 2001 From: jdsika Date: Sat, 28 Mar 2026 11:00:26 +0100 Subject: [PATCH 52/78] chore: pin OMB with OWL xsd-anyuri-as-iri consistency fix Updates ontology-management-base pin to include: - --xsd-anyuri-as-iri flag on gen-owl for GX artifacts - Promotes 9 URI properties from DatatypeProperty to ObjectProperty - Aligns OWL/SHACL/JSON-LD cross-generator semantics Signed-off-by: jdsika --- submodules/ontology-management-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index 40f45c5..48a19bd 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit 40f45c57279badb02136765f3226cdd2ad3c1ec1 +Subproject commit 48a19bdc69f3cca507e59b68ec080d1f2f5f267e From a42467865a24acc775f21e9a19770627813bcba7 Mon Sep 17 00:00:00 2001 From: jdsika Date: Sat, 28 Mar 2026 15:26:42 +0100 Subject: [PATCH 53/78] chore: update OMB pin (hybrid deterministic serializer) Signed-off-by: jdsika --- submodules/ontology-management-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index 48a19bd..74b7895 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit 48a19bdc69f3cca507e59b68ec080d1f2f5f267e +Subproject commit 74b7895138a1cf756c9285910ac74ad81ab0f548 From 2f063843d616b6604984c33da479b8b0082f01c1 Mon Sep 17 00:00:00 2001 From: jdsika Date: Mon, 30 Mar 2026 14:21:06 +0200 Subject: [PATCH 54/78] chore: update OMB submodule pin (rebased linkml) Signed-off-by: jdsika --- submodules/ontology-management-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index 74b7895..ab7191a 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit 74b7895138a1cf756c9285910ac74ad81ab0f548 +Subproject commit ab7191acd3cd6dddb0a047d912ce43ca2ea8cadd From 75d9fdb50eb85476878c559fe1f4f2aeb06520e9 Mon Sep 17 00:00:00 2001 From: jdsika Date: Mon, 30 Mar 2026 14:42:12 +0200 Subject: [PATCH 55/78] chore: update OMB submodule pin (openlabel-v2 artifacts) Signed-off-by: jdsika --- submodules/ontology-management-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index ab7191a..3707f62 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit ab7191acd3cd6dddb0a047d912ce43ca2ea8cadd +Subproject commit 3707f62b8c960e84e3e8a7a487cf4d4751390861 From eff7eb34699cd976a482735f50490927fa65574d Mon Sep 17 00:00:00 2001 From: jdsika Date: Mon, 30 Mar 2026 15:35:01 +0200 Subject: [PATCH 56/78] fix(make): install linkml from fork submodule instead of PyPI The generate pipeline requires ASCS-eV/linkml fork features (deterministic output, normalize-prefixes, xsd-anyuri-as-iri, exclude-external-imports). Previously 'make install dev' ran 'pip install linkml' which installed upstream PyPI linkml without these features, causing: TypeError: OwlSchemaGenerator.__init__() got an unexpected keyword argument 'deterministic' Changes: - Add LINKML_SUBMODULE_DIR variable pointing to OMB's linkml submodule - Move linkml installation from _install_dev to _setup_submodules, installing from the fork submodule (editable mode) - Remove standalone 'pip install linkml' from all targets - Lint/test jobs no longer install linkml (not needed for those tasks) - CI generate-validate job installs deps + submodules before generation Signed-off-by: jdsika --- .github/workflows/ci.yml | 9 ++++----- Makefile | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ccead66..87c826d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,11 +68,10 @@ jobs: python-version: "3.12" cache: 'pip' - - name: Install dependencies - run: make install dev - - - name: Install ontology-management-base - run: make setup submodules + - name: Install dependencies and submodules + run: | + make install dev + make setup submodules - name: Generate artifacts run: make generate diff --git a/Makefile b/Makefile index 11dcf1c..83b54cc 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,7 @@ TS_DIR := src/typescript/harbour OMB_SUBMODULE_DIR := submodules/ontology-management-base +LINKML_SUBMODULE_DIR := $(OMB_SUBMODULE_DIR)/submodules/linkml/packages/linkml # Allow callers to override the venv path/tooling. VENV ?= .venv @@ -220,12 +221,11 @@ _setup_default: @echo "Checking Python virtual environment and dependencies..." ifdef CI @set -e; \ - if "$(PYTHON)" -c "import pre_commit, linkml" >/dev/null 2>&1; then \ + if "$(PYTHON)" -c "import pre_commit" >/dev/null 2>&1; then \ echo "OK: Python environment and dependencies are ready via $(PYTHON)"; \ else \ echo "CI environment missing dependencies; bootstrapping..."; \ $(PIP) install -e ".[dev]"; \ - $(PIP) install linkml; \ $(PRECOMMIT) install; \ fi else @@ -233,7 +233,7 @@ else if [ ! -f "$(PYTHON)" ]; then \ echo "Python virtual environment not found; bootstrapping..."; \ "$(MAKE)" --no-print-directory "$(ACTIVATE_SCRIPT)"; \ - elif "$(PYTHON)" -c "import pre_commit, linkml" >/dev/null 2>&1; then \ + elif "$(PYTHON)" -c "import pre_commit" >/dev/null 2>&1; then \ echo "OK: Python virtual environment and dependencies are ready at $(VENV)"; \ else \ echo "Python virtual environment found but dependencies are missing; bootstrapping..."; \ @@ -254,14 +254,21 @@ $(VENV_PYTHON): $(ACTIVATE_SCRIPT): $(VENV_PYTHON) @echo "Installing Python dependencies..." @$(PIP) install -e ".[dev]" - @$(PIP) install linkml @$(PRECOMMIT) install @echo "OK: Python development environment ready" -# Setup ontology-management-base submodule using the same active venv +# Setup ontology-management-base submodule and LinkML fork _setup_submodules: @echo "Setting up ontology-management-base submodule..." @set -e; \ + if [ -f "$(LINKML_SUBMODULE_DIR)/pyproject.toml" ]; then \ + $(PIP) install -e "$(LINKML_SUBMODULE_DIR)"; \ + echo "OK: LinkML (ASCS-eV fork) installed from submodule"; \ + else \ + echo "WARNING: LinkML submodule not found at $(LINKML_SUBMODULE_DIR)"; \ + echo " Run: git submodule update --init --recursive"; \ + exit 1; \ + fi; \ if [ -f "$(OMB_SUBMODULE_DIR)/setup.py" ] || [ -f "$(OMB_SUBMODULE_DIR)/pyproject.toml" ]; then \ $(PIP) install -e "$(OMB_SUBMODULE_DIR)"; \ echo "OK: ontology-management-base submodule setup complete"; \ @@ -311,7 +318,6 @@ ifndef CI @"$(MAKE)" --no-print-directory "$(VENV_PYTHON)" endif @$(PIP) install -e ".[dev]" - @$(PIP) install linkml ifndef CI @$(PRECOMMIT) install endif From ed3e4cff93640c8493f531aeb7ddbe79bfbd0a2d Mon Sep 17 00:00:00 2001 From: jdsika Date: Mon, 30 Mar 2026 15:38:21 +0200 Subject: [PATCH 57/78] fix(deps): add rdflib to dev dependencies for SHACL tests test_shacl_failures.py imports rdflib directly. Previously this was transitively available via 'pip install linkml', but now that linkml is only installed in the generate-validate job (from the fork submodule), test jobs need rdflib declared explicitly. Signed-off-by: jdsika --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 4de787e..6e711a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dev = [ "pre-commit==4.5.1", "pytest>=8.0.0", "pytest-cov>=6.0", + "rdflib>=7.0.0", ] docs = [ "mkdocs>=1.6.0", From 2c7acc6720afe30f64f6898cdfe4f8478cadba46 Mon Sep 17 00:00:00 2001 From: jdsika Date: Mon, 30 Mar 2026 15:45:24 +0200 Subject: [PATCH 58/78] fix(examples): remove _comment keys that violate closed SHACL shapes JSON-LD expansion maps _comment to harbour:_comment triples, which violate sh:closed shapes. Remove documentation-only _comment keys from the nested evidence example. Signed-off-by: jdsika --- examples/credential-with-nested-evidence.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/credential-with-nested-evidence.json b/examples/credential-with-nested-evidence.json index 9c3443d..34a31be 100644 --- a/examples/credential-with-nested-evidence.json +++ b/examples/credential-with-nested-evidence.json @@ -1,5 +1,4 @@ { - "_comment": "Demonstrates 2-level nested evidence: outer VC → evidence VP → inner VC → evidence VP → LinkedCredentialService VC", "@context": [ "https://www.w3.org/ns/credentials/v2", "https://w3id.org/reachhaven/harbour/core/v1/" @@ -40,7 +39,6 @@ "holder": "did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "verifiableCredential": [ { - "_comment": "Level-1 inner VC — itself carries evidence (the nesting)", "@context": [ "https://www.w3.org/ns/credentials/v2", "https://w3id.org/reachhaven/harbour/core/v1/" @@ -69,7 +67,6 @@ "CredentialEvidence" ], "verifiablePresentation": { - "_comment": "Level-2 evidence VP inside the inner VC", "@context": [ "https://www.w3.org/ns/credentials/v2", "https://w3id.org/reachhaven/harbour/core/v1/" From 908c2253837157c4353f9af550ddfac9fb1d4c14 Mon Sep 17 00:00:00 2001 From: jdsika Date: Mon, 30 Mar 2026 20:29:43 +0200 Subject: [PATCH 59/78] refactor(owlgen): use use_native_uris=False, remove equivalence patch Set use_native_uris=False on OwlSchemaGenerator so OWL class IRIs are derived from class_uri instead of default_prefix + ClassName. This makes the rdfs:subClassOf hierarchy use the same IRIs as SHACL sh:targetClass and JSON-LD @type, allowing RDFS inference to resolve the type hierarchy directly. Removes the _patch_owl_equivalences post-processing function (-51 lines) that was copying rdfs:subClassOf triples and adding owl:equivalentClass axioms to bridge the class_uri / native URI mismatch. Signed-off-by: jdsika --- src/python/harbour/generate_artifacts.py | 65 +++--------------------- 1 file changed, 7 insertions(+), 58 deletions(-) diff --git a/src/python/harbour/generate_artifacts.py b/src/python/harbour/generate_artifacts.py index ba20e02..d0d2477 100644 --- a/src/python/harbour/generate_artifacts.py +++ b/src/python/harbour/generate_artifacts.py @@ -16,6 +16,12 @@ non-standard prefix aliases (e.g. ``sdo`` → ``schema``, ``dce`` → ``dc``) to their canonical well-known names, producing cleaner and more portable artifacts. + +The ``use_native_uris=False`` flag makes the OWL generator use ``class_uri`` +as the primary OWL class IRI instead of ``default_prefix + ClassName``. This +ensures the ``rdfs:subClassOf`` hierarchy uses the same IRIs as SHACL +``sh:targetClass`` and JSON-LD ``@type``, allowing RDFS inference to resolve +the type hierarchy without post-processing equivalence patches. """ import json @@ -24,7 +30,6 @@ from linkml.generators.jsonldcontextgen import ContextGenerator from linkml.generators.owlgen import OwlSchemaGenerator from linkml.generators.shaclgen import ShaclGenerator -from rdflib import OWL, RDFS, URIRef REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent LINKML_DIR = REPO_ROOT / "linkml" @@ -72,19 +77,12 @@ def main() -> None: mergeimports=False, deterministic=True, normalize_prefixes=True, + use_native_uris=False, importmap=importmap, base_dir=base_dir, ) owl_text = owl_gen.serialize() - # Post-process OWL: add owl:equivalentClass triples for classes - # whose class_uri differs from the default OWL class IRI. - # LinkML generates OWL class IRIs as default_prefix + ClassName, - # but class_uri (used for SHACL targetClass and JSON-LD context) - # may differ. Without equivalence, RDFS inference cannot chain - # the subclass hierarchy through class_uri URIs. - owl_text = _patch_owl_equivalences(owl_gen, owl_text) - (out_dir / f"{domain}.owl.ttl").write_text(owl_text, encoding="utf-8") if domain not in SHACL_SKIP_DOMAINS: @@ -126,54 +124,5 @@ def main() -> None: print(f"\nDone: {ARTIFACTS_DIR}/") -def _patch_owl_equivalences(owl_gen: OwlSchemaGenerator, owl_text: str) -> str: - """Add rdfs:subClassOf triples where class_uri differs from the - default OWL class IRI (default_prefix + ClassName). - - LinkML generates OWL using default_prefix + class_name as the class IRI, - but class_uri (which controls SHACL targetClass and JSON-LD type mapping) - can be set to a different URI. Downstream validators that rely on RDFS - inference need the subclass chain to be reachable from class_uri URIs. - - For each class where class_uri != owl_uri, we copy all rdfs:subClassOf - triples from the owl_uri class to the class_uri URI. This ensures RDFS - inference (which doesn't understand owl:equivalentClass) can resolve - the type hierarchy via class_uri URIs used in instance data. - """ - from rdflib import Graph - - sv = owl_gen.schemaview - schema = sv.schema - default_pfx = schema.default_prefix or "" - pfx_map = {p.prefix_prefix: p.prefix_reference for p in schema.prefixes.values()} - default_ns = pfx_map.get(default_pfx, "") - - equivalences: list[tuple[str, str]] = [] - for cls_name, cls_def in sv.all_classes().items(): - if cls_def.class_uri: - class_uri_str = sv.get_uri(cls_def, expand=True) - owl_uri = f"{default_ns}{cls_name}" - if class_uri_str and class_uri_str != owl_uri: - equivalences.append((owl_uri, class_uri_str)) - - if not equivalences: - return owl_text - - g = Graph() - g.parse(data=owl_text, format="turtle") - for owl_uri, class_uri_str in equivalences: - owl_ref = URIRef(owl_uri) - cu_ref = URIRef(class_uri_str) - if (owl_ref, None, None) in g: - # Copy rdfs:subClassOf triples from owl_uri to class_uri - for _, _, parent in g.triples((owl_ref, RDFS.subClassOf, None)): - if isinstance(parent, URIRef): - g.add((cu_ref, RDFS.subClassOf, parent)) - # Also add equivalence for OWL reasoners - g.add((owl_ref, OWL.equivalentClass, cu_ref)) - - return g.serialize(format="turtle") - - if __name__ == "__main__": main() From f4ac3f59e52e79de0423f39e5a683dc7f1d427bd Mon Sep 17 00:00:00 2001 From: jdsika Date: Tue, 31 Mar 2026 12:25:12 +0200 Subject: [PATCH 60/78] refactor(linkml): standardize type IRIs with prefixed CURIEs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove redundant 'Harbour' prefix from class_uris — the namespace prefix already establishes context: - harbour:HarbourCredential → harbour:Credential - harbour:HarbourPresentation → harbour:Presentation - (implicit) HarbourVerifiable* → harbour:VerifiableCredential, harbour:VerifiablePresentation Clean up delegation evidence naming: - harbour:DelegatedSignatureEvidence → harbour:SignatureEvidence (stays in core where it is defined) - harbour:DelegatedSigningReceipt → harbour.delegate:SigningReceipt (receipt type only used in delegation examples) Convention: W3C terms stay bare ('VerifiableCredential'), all domain types use prefixed CURIEs ('harbour:VerifiableCredential'). Follows the same pattern as harbour.gx:LegalPerson, harbour.gx:NaturalPerson. Update all examples, tests (Python + TypeScript), and sd_jwt_vp.py evidence type whitelist. LinkML class names unchanged (internal). Signed-off-by: jdsika --- examples/credential-with-evidence.json | 16 +++++----- examples/credential-with-nested-evidence.json | 26 ++++++++--------- .../did-ethr/harbour-signing-service.did.json | 6 ++-- .../did-ethr/harbour-trust-anchor.did.json | 2 +- examples/gaiax/delegated-signing-receipt.json | 18 ++++++------ .../legal-person-credential-embedded.json | 16 +++++----- examples/gaiax/legal-person-credential.json | 16 +++++----- examples/gaiax/natural-person-credential.json | 26 ++++++++--------- examples/gaiax/participant-vp.json | 18 ++++++------ examples/gaiax/trust-anchor-credential.json | 10 ++++--- linkml/harbour-core-credential.yaml | 29 +++++++++++-------- src/python/harbour/sd_jwt_vp.py | 2 ++ .../python/credentials/test_example_signer.py | 6 ++-- tests/python/credentials/test_validation.py | 19 +++++++----- tests/python/harbour/test_sd_jwt_vp.py | 26 ++++++++--------- tests/typescript/harbour/sd-jwt-vp.test.ts | 14 ++++----- 16 files changed, 131 insertions(+), 119 deletions(-) diff --git a/examples/credential-with-evidence.json b/examples/credential-with-evidence.json index 2a7b385..04440c5 100644 --- a/examples/credential-with-evidence.json +++ b/examples/credential-with-evidence.json @@ -5,7 +5,7 @@ ], "type": [ "VerifiableCredential", - "HarbourVerifiableCredential" + "harbour:VerifiableCredential" ], "id": "urn:uuid:11111111-1111-1111-1111-111111111111", "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", @@ -17,15 +17,15 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/a1b2c3d4e5f67890", - "type": "CRSetEntry", + "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } ], "evidence": [ { "type": [ - "Evidence", - "CredentialEvidence" + "harbour:Evidence", + "harbour:CredentialEvidence" ], "verifiablePresentation": { "@context": [ @@ -33,7 +33,7 @@ ], "type": [ "VerifiablePresentation", - "HarbourVerifiablePresentation" + "harbour:VerifiablePresentation" ], "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "verifiableCredential": [ @@ -44,13 +44,13 @@ ], "type": [ "VerifiableCredential", - "HarbourVerifiableCredential" + "harbour:VerifiableCredential" ], "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", - "type": "LinkedCredentialService", + "type": "harbour:LinkedCredentialService", "didcore:serviceEndpoint": { "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" } @@ -58,7 +58,7 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774:services:revocation-registry/lcs00000001", - "type": "CRSetEntry", + "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } ] diff --git a/examples/credential-with-nested-evidence.json b/examples/credential-with-nested-evidence.json index 34a31be..7960399 100644 --- a/examples/credential-with-nested-evidence.json +++ b/examples/credential-with-nested-evidence.json @@ -5,7 +5,7 @@ ], "type": [ "VerifiableCredential", - "HarbourVerifiableCredential" + "harbour:VerifiableCredential" ], "id": "urn:uuid:22222222-2222-2222-2222-222222222222", "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", @@ -17,15 +17,15 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/b2c3d4e5f6a78901", - "type": "CRSetEntry", + "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } ], "evidence": [ { "type": [ - "Evidence", - "CredentialEvidence" + "harbour:Evidence", + "harbour:CredentialEvidence" ], "verifiablePresentation": { "@context": [ @@ -34,7 +34,7 @@ ], "type": [ "VerifiablePresentation", - "HarbourVerifiablePresentation" + "harbour:VerifiablePresentation" ], "holder": "did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", "verifiableCredential": [ @@ -45,7 +45,7 @@ ], "type": [ "VerifiableCredential", - "HarbourVerifiableCredential" + "harbour:VerifiableCredential" ], "id": "urn:uuid:33333333-3333-3333-3333-333333333333", "issuer": "did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", @@ -56,15 +56,15 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3:services:revocation-registry/c3d4e5f6a7b89012", - "type": "CRSetEntry", + "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } ], "evidence": [ { "type": [ - "Evidence", - "CredentialEvidence" + "harbour:Evidence", + "harbour:CredentialEvidence" ], "verifiablePresentation": { "@context": [ @@ -73,7 +73,7 @@ ], "type": [ "VerifiablePresentation", - "HarbourVerifiablePresentation" + "harbour:VerifiablePresentation" ], "holder": "did:ethr:0x14a34:0xe5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", "verifiableCredential": [ @@ -84,13 +84,13 @@ ], "type": [ "VerifiableCredential", - "HarbourVerifiableCredential" + "harbour:VerifiableCredential" ], "issuer": "did:ethr:0x14a34:0xe5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0xe5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", - "type": "LinkedCredentialService", + "type": "harbour:LinkedCredentialService", "didcore:serviceEndpoint": { "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xe5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5" } @@ -98,7 +98,7 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0xe5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5:services:revocation-registry/lcs00000003", - "type": "CRSetEntry", + "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } ] diff --git a/examples/did-ethr/harbour-signing-service.did.json b/examples/did-ethr/harbour-signing-service.did.json index 4090dfd..036f554 100644 --- a/examples/did-ethr/harbour-signing-service.did.json +++ b/examples/did-ethr/harbour-signing-service.did.json @@ -47,7 +47,7 @@ "service": [ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#service-1", - "type": "TrustAnchorService", + "type": "harbour:TrustAnchorService", "didcore:serviceEndpoint": { "type": "schema:Organization", "name": "Haven Trust Anchor", @@ -56,9 +56,9 @@ }, { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#service-2", - "type": "CRSetRevocationRegistryService", + "type": "harbour:CRSetRevocationRegistryService", "didcore:serviceEndpoint": { - "type": "CRSetServiceEndpoint", + "type": "harbour:CRSetServiceEndpoint", "registryEndpoint": "https://resolver.harbour.id/crset/" } } diff --git a/examples/did-ethr/harbour-trust-anchor.did.json b/examples/did-ethr/harbour-trust-anchor.did.json index 5dc56b1..a7429cf 100644 --- a/examples/did-ethr/harbour-trust-anchor.did.json +++ b/examples/did-ethr/harbour-trust-anchor.did.json @@ -33,7 +33,7 @@ "service": [ { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774#service-1", - "type": "LinkedCredentialService", + "type": "harbour:LinkedCredentialService", "serviceEndpoint": "https://reachhaven.com/.well-known/credentials/trust-anchor.jwt" } ] diff --git a/examples/gaiax/delegated-signing-receipt.json b/examples/gaiax/delegated-signing-receipt.json index 4146c96..280c866 100644 --- a/examples/gaiax/delegated-signing-receipt.json +++ b/examples/gaiax/delegated-signing-receipt.json @@ -7,30 +7,30 @@ ], "type": [ "VerifiableCredential", - "HarbourVerifiableCredential", - "harbour:DelegatedSigningReceipt" + "harbour:VerifiableCredential", + "harbour.delegate:SigningReceipt" ], "id": "urn:uuid:f7e8d9c0-b1a2-3456-7890-abcdef012345", "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "validFrom": "2025-06-25T10:00:00Z", "credentialSubject": { "id": "urn:uuid:receipt-b7c8d9e0-f1a2-3456-789a-bcdef0123456", - "type": "TransactionReceipt", + "type": "harbour:TransactionReceipt", "transactionHash": "c3d4ba771c1103935ab4121874c4b3a78c8471719c80f60d59ca5811e232089b", "blockchainTxId": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" }, "credentialStatus": [ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/f7e8d9c0b1a23456", - "type": "CRSetEntry", + "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } ], "evidence": [ { "type": [ - "Evidence", - "DelegatedSignatureEvidence" + "harbour:Evidence", + "harbour:SignatureEvidence" ], "verifiablePresentation": { "@context": [ @@ -38,7 +38,7 @@ ], "type": [ "VerifiablePresentation", - "HarbourVerifiablePresentation" + "harbour:VerifiablePresentation" ], "holder": "did:jwk:eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6IjZka1U2Wk1GSzc5V3dpY3dKNXJieEUxM3pTdWtCWTJPb0VpVlVFanFNRWMiLCJ5IjoiUm5Iem55VmxyUFNNVDdpckRzMTVEOXd4Z01vamlTREFRcGZGaHFUa0xSWSJ9", "verifiableCredential": [ @@ -50,7 +50,7 @@ ], "type": [ "VerifiableCredential", - "HarbourVerifiableCredential" + "harbour:VerifiableCredential" ], "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", "validFrom": "2024-01-15T00:00:00Z", @@ -62,7 +62,7 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/np00000001", - "type": "CRSetEntry", + "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } ] diff --git a/examples/gaiax/legal-person-credential-embedded.json b/examples/gaiax/legal-person-credential-embedded.json index 5cb4d3f..358ccbb 100644 --- a/examples/gaiax/legal-person-credential-embedded.json +++ b/examples/gaiax/legal-person-credential-embedded.json @@ -7,7 +7,7 @@ ], "type": [ "VerifiableCredential", - "HarbourVerifiableCredential", + "harbour:VerifiableCredential", "harbour.gx:LegalPersonCredential" ], "id": "urn:uuid:e1e2e3e4-e5e6-e7e8-e9e0-e1e2e3e4e5e6", @@ -48,15 +48,15 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/e1e2e3e4e5e6e7e8", - "type": "CRSetEntry", + "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } ], "evidence": [ { "type": [ - "Evidence", - "CredentialEvidence" + "harbour:Evidence", + "harbour:CredentialEvidence" ], "verifiablePresentation": { "@context": [ @@ -64,7 +64,7 @@ ], "type": [ "VerifiablePresentation", - "HarbourVerifiablePresentation" + "harbour:VerifiablePresentation" ], "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "verifiableCredential": [ @@ -75,13 +75,13 @@ ], "type": [ "VerifiableCredential", - "HarbourVerifiableCredential" + "harbour:VerifiableCredential" ], "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", - "type": "LinkedCredentialService", + "type": "harbour:LinkedCredentialService", "didcore:serviceEndpoint": { "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" } @@ -89,7 +89,7 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774:services:revocation-registry/lcs00000001", - "type": "CRSetEntry", + "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } ] diff --git a/examples/gaiax/legal-person-credential.json b/examples/gaiax/legal-person-credential.json index 95053e7..d75b01b 100644 --- a/examples/gaiax/legal-person-credential.json +++ b/examples/gaiax/legal-person-credential.json @@ -7,7 +7,7 @@ ], "type": [ "VerifiableCredential", - "HarbourVerifiableCredential", + "harbour:VerifiableCredential", "harbour.gx:LegalPersonCredential" ], "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", @@ -45,15 +45,15 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/a1b2c3d4e5f67890", - "type": "CRSetEntry", + "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } ], "evidence": [ { "type": [ - "Evidence", - "CredentialEvidence" + "harbour:Evidence", + "harbour:CredentialEvidence" ], "verifiablePresentation": { "@context": [ @@ -61,7 +61,7 @@ ], "type": [ "VerifiablePresentation", - "HarbourVerifiablePresentation" + "harbour:VerifiablePresentation" ], "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "verifiableCredential": [ @@ -72,13 +72,13 @@ ], "type": [ "VerifiableCredential", - "HarbourVerifiableCredential" + "harbour:VerifiableCredential" ], "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", - "type": "LinkedCredentialService", + "type": "harbour:LinkedCredentialService", "didcore:serviceEndpoint": { "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" } @@ -86,7 +86,7 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774:services:revocation-registry/lcs00000001", - "type": "CRSetEntry", + "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } ] diff --git a/examples/gaiax/natural-person-credential.json b/examples/gaiax/natural-person-credential.json index 2542f62..1820406 100644 --- a/examples/gaiax/natural-person-credential.json +++ b/examples/gaiax/natural-person-credential.json @@ -7,7 +7,7 @@ ], "type": [ "VerifiableCredential", - "HarbourVerifiableCredential", + "harbour:VerifiableCredential", "harbour.gx:NaturalPersonCredential" ], "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f23456789012", @@ -31,15 +31,15 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/b2c3d4e5f6a78901", - "type": "CRSetEntry", + "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } ], "evidence": [ { "type": [ - "Evidence", - "CredentialEvidence" + "harbour:Evidence", + "harbour:CredentialEvidence" ], "verifiablePresentation": { "@context": [ @@ -50,7 +50,7 @@ ], "type": [ "VerifiablePresentation", - "HarbourVerifiablePresentation" + "harbour:VerifiablePresentation" ], "holder": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", "verifiableCredential": [ @@ -63,7 +63,7 @@ ], "type": [ "VerifiableCredential", - "HarbourVerifiableCredential", + "harbour:VerifiableCredential", "harbour.gx:LegalPersonCredential" ], "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567899", @@ -99,15 +99,15 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/a1b2c3d4e5f67899", - "type": "CRSetEntry", + "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } ], "evidence": [ { "type": [ - "Evidence", - "CredentialEvidence" + "harbour:Evidence", + "harbour:CredentialEvidence" ], "verifiablePresentation": { "@context": [ @@ -116,7 +116,7 @@ ], "type": [ "VerifiablePresentation", - "HarbourVerifiablePresentation" + "harbour:VerifiablePresentation" ], "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "verifiableCredential": [ @@ -127,13 +127,13 @@ ], "type": [ "VerifiableCredential", - "HarbourVerifiableCredential" + "harbour:VerifiableCredential" ], "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", - "type": "LinkedCredentialService", + "type": "harbour:LinkedCredentialService", "didcore:serviceEndpoint": { "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" } @@ -141,7 +141,7 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774:services:revocation-registry/lcs00000001", - "type": "CRSetEntry", + "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } ] diff --git a/examples/gaiax/participant-vp.json b/examples/gaiax/participant-vp.json index aa11826..7785ab9 100644 --- a/examples/gaiax/participant-vp.json +++ b/examples/gaiax/participant-vp.json @@ -7,7 +7,7 @@ ], "type": [ "VerifiablePresentation", - "HarbourVerifiablePresentation" + "harbour:VerifiablePresentation" ], "holder": "did:ethr:0x14a34:0xd1d2d3d4d5d6d7d8d9d0d1d2d3d4d5d6d7d8d9d0", "verifiableCredential": [ @@ -103,7 +103,7 @@ ], "type": [ "VerifiableCredential", - "HarbourVerifiableCredential", + "harbour:VerifiableCredential", "harbour.gx:LegalPersonCredential" ], "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", @@ -141,15 +141,15 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/a1b2c3d4e5f67890", - "type": "CRSetEntry", + "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } ], "evidence": [ { "type": [ - "Evidence", - "CredentialEvidence" + "harbour:Evidence", + "harbour:CredentialEvidence" ], "verifiablePresentation": { "@context": [ @@ -157,7 +157,7 @@ ], "type": [ "VerifiablePresentation", - "HarbourVerifiablePresentation" + "harbour:VerifiablePresentation" ], "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "verifiableCredential": [ @@ -168,13 +168,13 @@ ], "type": [ "VerifiableCredential", - "HarbourVerifiableCredential" + "harbour:VerifiableCredential" ], "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", - "type": "LinkedCredentialService", + "type": "harbour:LinkedCredentialService", "didcore:serviceEndpoint": { "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" } @@ -182,7 +182,7 @@ "credentialStatus": [ { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774:services:revocation-registry/lcs00000001", - "type": "CRSetEntry", + "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } ] diff --git a/examples/gaiax/trust-anchor-credential.json b/examples/gaiax/trust-anchor-credential.json index f4ababe..e8bc185 100644 --- a/examples/gaiax/trust-anchor-credential.json +++ b/examples/gaiax/trust-anchor-credential.json @@ -5,20 +5,22 @@ ], "type": [ "VerifiableCredential", - "HarbourVerifiableCredential" + "harbour:VerifiableCredential" ], "id": "urn:uuid:c4d5e6f7-a8b9-0123-cdef-456789abcdef", "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2024-01-01T00:00:00Z", "credentialSubject": { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", - "type": "LinkedCredentialService", - "didcore:serviceEndpoint": {"id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774"} + "type": "harbour:LinkedCredentialService", + "didcore:serviceEndpoint": { + "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" + } }, "credentialStatus": [ { "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774:services:revocation-registry/c4d5e6f7a8b90123", - "type": "CRSetEntry", + "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } ] diff --git a/linkml/harbour-core-credential.yaml b/linkml/harbour-core-credential.yaml index 1f58a1c..ba38d76 100644 --- a/linkml/harbour-core-credential.yaml +++ b/linkml/harbour-core-credential.yaml @@ -50,7 +50,7 @@ description: > # - "type": "@type" manually injected — LinkML cannot emit this alias # without declaring a "type" slot that would conflict with [VC-CTX]'s # @protected definition of "type". -# - OWL owl:equivalentClass axioms patched in for Gaia-X alignment +# - OWL owl:equivalentClass axioms for Gaia-X alignment # (HarbourLegalPerson ≡ gx:LegalPerson, etc.) — domain logic, not # a generator limitation. # @@ -72,6 +72,7 @@ prefixes: cred: https://www.w3.org/2018/credentials# didcore: https://www.w3.org/ns/did# rdfs: http://www.w3.org/2000/01/rdf-schema# + harbour.delegate: https://w3id.org/reachhaven/harbour/delegate/v1/ default_prefix: harbour default_range: string @@ -385,7 +386,7 @@ classes: description: > Abstract base for all Harbour credentials. Requires issuer, validFrom, and credentialStatus with at least one CRSetEntry for revocation support. - class_uri: harbour:HarbourCredential + class_uri: harbour:Credential slots: - issuer - validFrom @@ -429,12 +430,14 @@ classes: No domain-specific credentialSubject requirements — any subject is valid. Domain layers (e.g. harbour-gx-credential) define specialized credential types with typed subjects. - Named 'HarbourVerifiableCredential' (not 'VerifiableCredential') to - avoid JSON-LD @protected term collision with W3C VC v2 context, - which defines 'VerifiableCredential' as a protected term mapping to - https://www.w3.org/2018/credentials#VerifiableCredential. + Named 'HarbourVerifiableCredential' internally to avoid LinkML class + name collision with the W3C VC term. The class_uri harbour:VerifiableCredential + is the canonical IRI; in JSON-LD examples use the prefixed CURIE + 'harbour:VerifiableCredential' (the bare 'VerifiableCredential' is + @protected by the W3C VC v2 context). + class_uri: harbour:VerifiableCredential annotations: - vct: "https://w3id.org/reachhaven/harbour/core/v1/HarbourVerifiableCredential" + vct: "https://w3id.org/reachhaven/harbour/core/v1/VerifiableCredential" # ========================================== # 3b. PRESENTATION TYPES @@ -449,7 +452,7 @@ classes: Abstract base for all Harbour presentations. Requires holder and at least one verifiableCredential. On the wire, presentations are encoded as VC-JOSE-COSE compact JWS (typ: vp+jwt) or SD-JWT VP. - class_uri: harbour:HarbourPresentation + class_uri: harbour:Presentation slots: - holder - verifiableCredential @@ -471,10 +474,12 @@ classes: HarbourVerifiableCredential instances for transmission. Evidence VPs embedded in credentials use this type. Domain layers may define specialized presentation types if needed. - Named 'HarbourVerifiablePresentation' (not 'VerifiablePresentation') - to avoid JSON-LD @protected term collision with W3C VC v2 context. + Named 'HarbourVerifiablePresentation' internally to avoid LinkML + class name collision with the W3C VC term. The class_uri + harbour:VerifiablePresentation is the canonical IRI. + class_uri: harbour:VerifiablePresentation annotations: - vpt: "https://w3id.org/reachhaven/harbour/core/v1/HarbourVerifiablePresentation" + vpt: "https://w3id.org/reachhaven/harbour/core/v1/VerifiablePresentation" # ========================================== # 4. EVIDENCE TYPES @@ -525,7 +530,7 @@ classes: executed a transaction with the user's explicit consent. The consent VP uses SD-JWT with PII redacted. Transaction data is a disclosable claim enabling three-layer privacy (public / authorized / full audit). - class_uri: harbour:DelegatedSignatureEvidence + class_uri: harbour:SignatureEvidence slots: - verifiablePresentation - delegatedTo diff --git a/src/python/harbour/sd_jwt_vp.py b/src/python/harbour/sd_jwt_vp.py index 1417546..305a3f5 100644 --- a/src/python/harbour/sd_jwt_vp.py +++ b/src/python/harbour/sd_jwt_vp.py @@ -51,6 +51,8 @@ DELEGATED_EVIDENCE_TYPES = { "DelegatedSignatureEvidence", "harbour:DelegatedSignatureEvidence", + "harbour:SignatureEvidence", + "harbour.delegate:SignatureEvidence", } diff --git a/tests/python/credentials/test_example_signer.py b/tests/python/credentials/test_example_signer.py index 82b8b79..e40aa09 100644 --- a/tests/python/credentials/test_example_signer.py +++ b/tests/python/credentials/test_example_signer.py @@ -180,15 +180,15 @@ def test_process_delegated_signing_receipt(self, signing_key, tmp_path): # Verify outer VC JWT vc_jwt = jwt_path.read_text().strip() vc_payload = verify_vc_jose(vc_jwt, public_key) - assert "harbour:DelegatedSigningReceipt" in vc_payload["type"] + assert "harbour.delegate:SigningReceipt" in vc_payload["type"] # Evidence should contain DelegatedSignatureEvidence with transaction_data evidence = vc_payload["evidence"][0] ev_type = evidence["type"] if isinstance(ev_type, list): - assert "DelegatedSignatureEvidence" in ev_type + assert "harbour:SignatureEvidence" in ev_type else: - assert ev_type == "DelegatedSignatureEvidence" + assert ev_type == "harbour:SignatureEvidence" assert "transaction_data" in evidence assert evidence["transaction_data"]["type"] == "harbour.delegate:data.purchase" assert ( diff --git a/tests/python/credentials/test_validation.py b/tests/python/credentials/test_validation.py index 9d1a6bb..1b4c608 100644 --- a/tests/python/credentials/test_validation.py +++ b/tests/python/credentials/test_validation.py @@ -117,7 +117,7 @@ def test_has_credential_status(credential_file): f"Missing credentialStatus in {credential_file.name}" ) for entry in status: - assert entry.get("type") == "CRSetEntry" + assert entry.get("type") == "harbour:CRSetEntry" assert "statusPurpose" in entry @@ -266,10 +266,10 @@ def test_shacl_is_non_empty(self): def test_shacl_has_base_shapes(self): content = HARBOUR_SHACL_PATH.read_text() expected_shapes = [ - "harbour:HarbourCredential", + "harbour:Credential", "harbour:CRSetEntry", "harbour:CredentialEvidence", - "harbour:DelegatedSignatureEvidence", + "harbour:SignatureEvidence", ] for shape in expected_shapes: assert f"{shape} a sh:NodeShape" in content, ( @@ -277,10 +277,10 @@ def test_shacl_has_base_shapes(self): ) def test_harbour_credential_shape_has_issuer(self): - """HarbourCredential shape must include cred:issuer as required.""" + """Credential shape must include cred:issuer as required.""" content = HARBOUR_SHACL_PATH.read_text() - marker = "harbour:HarbourCredential a sh:NodeShape" - assert marker in content, "Missing shape for HarbourCredential" + marker = "harbour:Credential a sh:NodeShape" + assert marker in content, "Missing shape for harbour:Credential" shape_start = content.index(marker) next_shape = content.find("\n\n", shape_start + 1) if next_shape == -1: @@ -293,8 +293,11 @@ def test_harbour_credential_shape_has_issuer(self): def test_evidence_shapes_require_verifiable_presentation(self): """Evidence shapes must require verifiablePresentation.""" content = HARBOUR_SHACL_PATH.read_text() - for ev_type in ["CredentialEvidence", "DelegatedSignatureEvidence"]: - marker = f"harbour:{ev_type} a sh:NodeShape" + for ev_ns, ev_type in [ + ("harbour", "CredentialEvidence"), + ("harbour", "SignatureEvidence"), + ]: + marker = f"{ev_ns}:{ev_type} a sh:NodeShape" shape_start = content.index(marker) next_shape = content.find("\n\n", shape_start + 1) if next_shape == -1: diff --git a/tests/python/harbour/test_sd_jwt_vp.py b/tests/python/harbour/test_sd_jwt_vp.py index cbcaf84..300c316 100644 --- a/tests/python/harbour/test_sd_jwt_vp.py +++ b/tests/python/harbour/test_sd_jwt_vp.py @@ -158,7 +158,7 @@ def test_issue_with_evidence(self, sample_sd_jwt_vc, holder_keypair): evidence = [ { - "type": "DelegatedSignatureEvidence", + "type": "harbour:SignatureEvidence", "transaction_data": { "type": "harbour.delegate:data.purchase", "credential_ids": ["harbour_natural_person"], @@ -192,7 +192,7 @@ def test_issue_with_evidence(self, sample_sd_jwt_vc, holder_keypair): assert "vp" in vp_payload assert "evidence" in vp_payload["vp"] assert len(vp_payload["vp"]["evidence"]) == 1 - assert vp_payload["vp"]["evidence"][0]["type"] == "DelegatedSignatureEvidence" + assert vp_payload["vp"]["evidence"][0]["type"] == "harbour:SignatureEvidence" assert vp_payload["vp"]["evidence"][0]["challenge"] == expected_challenge assert vp_payload["nonce"] == tx_nonce assert vp_payload["aud"] == audience @@ -204,7 +204,7 @@ def test_issue_keeps_transaction_data_field(self, sample_sd_jwt_vc, holder_keypa holder_private, _ = holder_keypair evidence = [ { - "type": "DelegatedSignatureEvidence", + "type": "harbour:SignatureEvidence", "transaction_data": { "type": "harbour.delegate:data.purchase", "credential_ids": ["default"], @@ -329,7 +329,7 @@ def test_verify_evidence(self, sample_sd_jwt_vc, issuer_keypair, holder_keypair) evidence = [ { - "type": "DelegatedSignatureEvidence", + "type": "harbour:SignatureEvidence", "transaction_data": { "type": "harbour.delegate:blockchain.approve", "credential_ids": ["default"], @@ -350,7 +350,7 @@ def test_verify_evidence(self, sample_sd_jwt_vc, issuer_keypair, holder_keypair) assert "evidence" in result assert len(result["evidence"]) == 1 - assert result["evidence"][0]["type"] == "DelegatedSignatureEvidence" + assert result["evidence"][0]["type"] == "harbour:SignatureEvidence" def test_verify_fails_transaction_hash_mismatch( self, sample_sd_jwt_vc, issuer_keypair, holder_keypair @@ -362,7 +362,7 @@ def test_verify_fails_transaction_hash_mismatch( evidence = [ { - "type": "DelegatedSignatureEvidence", + "type": "harbour:SignatureEvidence", "transaction_data": { "type": "harbour.delegate:data.purchase", "credential_ids": ["default"], @@ -421,7 +421,7 @@ def test_verify_fails_when_transaction_data_missing( evidence = [ { - "type": "DelegatedSignatureEvidence", + "type": "harbour:SignatureEvidence", "transaction_data": { "type": "harbour.delegate:data.purchase", "credential_ids": ["default"], @@ -561,7 +561,7 @@ def test_delegated_consent_flow(self, issuer_keypair, holder_keypair): evidence = [ { - "type": "DelegatedSignatureEvidence", + "type": "harbour:SignatureEvidence", "transaction_data": transaction_data, "delegatedTo": signing_service_did, } @@ -605,7 +605,7 @@ def test_delegated_consent_flow(self, issuer_keypair, holder_keypair): # Evidence should contain transaction data assert len(result["evidence"]) == 1 ev = result["evidence"][0] - assert ev["type"] == "DelegatedSignatureEvidence" + assert ev["type"] == "harbour:SignatureEvidence" assert ev["transaction_data"]["type"] == "harbour.delegate:data.purchase" assert ev["transaction_data"]["nonce"] == consent_nonce assert ev["challenge"] == create_delegation_challenge( @@ -638,7 +638,7 @@ def test_public_audit_privacy(self, issuer_keypair, holder_keypair): # Create VP with no PII disclosed evidence = [ { - "type": "DelegatedSignatureEvidence", + "type": "harbour:SignatureEvidence", "transaction_data": { "type": "harbour.delegate:blockchain.transfer", "credential_ids": ["default"], @@ -661,7 +661,7 @@ def test_public_audit_privacy(self, issuer_keypair, holder_keypair): result = verify_sd_jwt_vp(vp, issuer_public, holder_public) # Can verify consent happened - assert result["evidence"][0]["type"] == "DelegatedSignatureEvidence" + assert result["evidence"][0]["type"] == "harbour:SignatureEvidence" # Can see authorized role assert result["credential"]["publicRole"] == "Authorized Purchaser" @@ -718,7 +718,7 @@ def test_multiple_evidence_items(self, sample_sd_jwt_vc, holder_keypair): evidence = [ { - "type": "DelegatedSignatureEvidence", + "type": "harbour:SignatureEvidence", "transaction_data": { "type": "harbour.delegate:data.share", "credential_ids": ["default"], @@ -727,7 +727,7 @@ def test_multiple_evidence_items(self, sample_sd_jwt_vc, holder_keypair): "txn": {"resource_id": "asset:xyz"}, }, }, - {"type": "CredentialEvidence", "verifiablePresentation": "eyJ..."}, + {"type": "harbour:CredentialEvidence", "verifiablePresentation": "eyJ..."}, ] vp = issue_sd_jwt_vp( diff --git a/tests/typescript/harbour/sd-jwt-vp.test.ts b/tests/typescript/harbour/sd-jwt-vp.test.ts index cb3bbba..e6eb7d8 100644 --- a/tests/typescript/harbour/sd-jwt-vp.test.ts +++ b/tests/typescript/harbour/sd-jwt-vp.test.ts @@ -124,7 +124,7 @@ describe("issueSdJwtVp", () => { const audience = "did:ethr:0x14a34:0xab645824d16971b89a2243f21881864ad57b9166"; const evidence = [ { - type: "DelegatedSignatureEvidence", + type: "harbour:SignatureEvidence", transaction_data: { type: "harbour.delegate:data.purchase", credential_ids: ["harbour_natural_person"], @@ -155,7 +155,7 @@ describe("issueSdJwtVp", () => { expect(vpPayload.vp.evidence).toHaveLength(1); expect(vpPayload.vp.evidence[0].type).toBe( - "DelegatedSignatureEvidence" + "harbour:SignatureEvidence" ); expect(vpPayload.vp.evidence[0].challenge).toBe(expectedChallenge); expect(vpPayload.nonce).toBe(txNonce); @@ -167,7 +167,7 @@ describe("issueSdJwtVp", () => { it("keeps delegated evidence transaction_data unchanged", async () => { const evidence = [ { - type: "DelegatedSignatureEvidence", + type: "harbour:SignatureEvidence", transaction_data: { type: "harbour.delegate:data.purchase", credential_ids: ["default"], @@ -253,7 +253,7 @@ describe("verifySdJwtVp", () => { it("returns evidence", async () => { const evidence = [ { - type: "DelegatedSignatureEvidence", + type: "harbour:SignatureEvidence", transaction_data: { type: "harbour.delegate:blockchain.approve", credential_ids: ["default"], @@ -268,14 +268,14 @@ describe("verifySdJwtVp", () => { const result = await verifySdJwtVp(vp, issuerPublic, holderPublic); expect(result.evidence).toHaveLength(1); - expect(result.evidence![0].type).toBe("DelegatedSignatureEvidence"); + expect(result.evidence![0].type).toBe("harbour:SignatureEvidence"); }); it("fails when transaction_data_hashes is tampered", async () => { const nonce = "tx-hash-nonce"; const evidence = [ { - type: "DelegatedSignatureEvidence", + type: "harbour:SignatureEvidence", transaction_data: { type: "harbour.delegate:data.purchase", credential_ids: ["default"], @@ -330,7 +330,7 @@ describe("verifySdJwtVp", () => { const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { evidence: [ { - type: "DelegatedSignatureEvidence", + type: "harbour:SignatureEvidence", transaction_data: { type: "harbour.delegate:data.purchase", credential_ids: ["default"], From 9f355d35ebdc704ea1d86c05553bbccb8e12e0e5 Mon Sep 17 00:00:00 2001 From: jdsika Date: Tue, 31 Mar 2026 15:32:24 +0200 Subject: [PATCH 61/78] refactor(examples): remove redundant parent types from evidence and credential arrays - Remove harbour:Evidence from type arrays where a child type (CredentialEvidence, SignatureEvidence) is present; RDFS inference infers the parent via rdfs:subClassOf - Remove harbour:VerifiableCredential from domain credential type arrays (LegalPerson, NaturalPerson, DelegatedSigningReceipt) where a domain-specific type already inherits all constraints from harbour:Credential - Refactor test_shacl_failures.py to use the OMB ShaclValidator (with RDFS inference enabled) instead of calling pyshacl directly with inference=none - Add harbour:SignatureEvidence to TypeScript DELEGATED_EVIDENCE_TYPES whitelist (was missing, causing 3 TS test failures) Signed-off-by: jdsika --- examples/credential-with-evidence.json | 1 - examples/credential-with-nested-evidence.json | 2 - examples/gaiax/delegated-signing-receipt.json | 2 - .../legal-person-credential-embedded.json | 2 - examples/gaiax/legal-person-credential.json | 2 - examples/gaiax/natural-person-credential.json | 4 - examples/gaiax/participant-vp.json | 2 - src/typescript/harbour/sd-jwt-vp.ts | 2 + .../python/credentials/test_shacl_failures.py | 148 ++++++++---------- 9 files changed, 68 insertions(+), 97 deletions(-) diff --git a/examples/credential-with-evidence.json b/examples/credential-with-evidence.json index 04440c5..a1e6a4e 100644 --- a/examples/credential-with-evidence.json +++ b/examples/credential-with-evidence.json @@ -24,7 +24,6 @@ "evidence": [ { "type": [ - "harbour:Evidence", "harbour:CredentialEvidence" ], "verifiablePresentation": { diff --git a/examples/credential-with-nested-evidence.json b/examples/credential-with-nested-evidence.json index 7960399..255088c 100644 --- a/examples/credential-with-nested-evidence.json +++ b/examples/credential-with-nested-evidence.json @@ -24,7 +24,6 @@ "evidence": [ { "type": [ - "harbour:Evidence", "harbour:CredentialEvidence" ], "verifiablePresentation": { @@ -63,7 +62,6 @@ "evidence": [ { "type": [ - "harbour:Evidence", "harbour:CredentialEvidence" ], "verifiablePresentation": { diff --git a/examples/gaiax/delegated-signing-receipt.json b/examples/gaiax/delegated-signing-receipt.json index 280c866..78d49f8 100644 --- a/examples/gaiax/delegated-signing-receipt.json +++ b/examples/gaiax/delegated-signing-receipt.json @@ -7,7 +7,6 @@ ], "type": [ "VerifiableCredential", - "harbour:VerifiableCredential", "harbour.delegate:SigningReceipt" ], "id": "urn:uuid:f7e8d9c0-b1a2-3456-7890-abcdef012345", @@ -29,7 +28,6 @@ "evidence": [ { "type": [ - "harbour:Evidence", "harbour:SignatureEvidence" ], "verifiablePresentation": { diff --git a/examples/gaiax/legal-person-credential-embedded.json b/examples/gaiax/legal-person-credential-embedded.json index 358ccbb..25e02f8 100644 --- a/examples/gaiax/legal-person-credential-embedded.json +++ b/examples/gaiax/legal-person-credential-embedded.json @@ -7,7 +7,6 @@ ], "type": [ "VerifiableCredential", - "harbour:VerifiableCredential", "harbour.gx:LegalPersonCredential" ], "id": "urn:uuid:e1e2e3e4-e5e6-e7e8-e9e0-e1e2e3e4e5e6", @@ -55,7 +54,6 @@ "evidence": [ { "type": [ - "harbour:Evidence", "harbour:CredentialEvidence" ], "verifiablePresentation": { diff --git a/examples/gaiax/legal-person-credential.json b/examples/gaiax/legal-person-credential.json index d75b01b..ac28be2 100644 --- a/examples/gaiax/legal-person-credential.json +++ b/examples/gaiax/legal-person-credential.json @@ -7,7 +7,6 @@ ], "type": [ "VerifiableCredential", - "harbour:VerifiableCredential", "harbour.gx:LegalPersonCredential" ], "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", @@ -52,7 +51,6 @@ "evidence": [ { "type": [ - "harbour:Evidence", "harbour:CredentialEvidence" ], "verifiablePresentation": { diff --git a/examples/gaiax/natural-person-credential.json b/examples/gaiax/natural-person-credential.json index 1820406..8ac2886 100644 --- a/examples/gaiax/natural-person-credential.json +++ b/examples/gaiax/natural-person-credential.json @@ -7,7 +7,6 @@ ], "type": [ "VerifiableCredential", - "harbour:VerifiableCredential", "harbour.gx:NaturalPersonCredential" ], "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f23456789012", @@ -38,7 +37,6 @@ "evidence": [ { "type": [ - "harbour:Evidence", "harbour:CredentialEvidence" ], "verifiablePresentation": { @@ -63,7 +61,6 @@ ], "type": [ "VerifiableCredential", - "harbour:VerifiableCredential", "harbour.gx:LegalPersonCredential" ], "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567899", @@ -106,7 +103,6 @@ "evidence": [ { "type": [ - "harbour:Evidence", "harbour:CredentialEvidence" ], "verifiablePresentation": { diff --git a/examples/gaiax/participant-vp.json b/examples/gaiax/participant-vp.json index 7785ab9..fe826d5 100644 --- a/examples/gaiax/participant-vp.json +++ b/examples/gaiax/participant-vp.json @@ -103,7 +103,6 @@ ], "type": [ "VerifiableCredential", - "harbour:VerifiableCredential", "harbour.gx:LegalPersonCredential" ], "id": "urn:uuid:a1b2c3d4-e5f6-7890-abcd-ef1234567890", @@ -148,7 +147,6 @@ "evidence": [ { "type": [ - "harbour:Evidence", "harbour:CredentialEvidence" ], "verifiablePresentation": { diff --git a/src/typescript/harbour/sd-jwt-vp.ts b/src/typescript/harbour/sd-jwt-vp.ts index 8adab04..ed381ac 100644 --- a/src/typescript/harbour/sd-jwt-vp.ts +++ b/src/typescript/harbour/sd-jwt-vp.ts @@ -22,6 +22,8 @@ const SD_JWT_SEPARATOR = "~"; const DELEGATED_EVIDENCE_TYPES = new Set([ "DelegatedSignatureEvidence", "harbour:DelegatedSignatureEvidence", + "harbour:SignatureEvidence", + "harbour.delegate:SignatureEvidence", ]); export interface IssueSdJwtVpOptions { diff --git a/tests/python/credentials/test_shacl_failures.py b/tests/python/credentials/test_shacl_failures.py index 41110f8..b3f9212 100644 --- a/tests/python/credentials/test_shacl_failures.py +++ b/tests/python/credentials/test_shacl_failures.py @@ -3,7 +3,10 @@ This test suite programmatically mutates valid credential examples and asserts that SHACL validation catches each specific error. Every test starts from a known-good credential, applies a single mutation, and -checks that pyshacl reports the expected violation. +checks that the OMB validation suite reports the expected violation. + +Validation uses the ``ShaclValidator`` from the ontology-management-base +submodule — the same pipeline used in production (RDFS inference enabled). The test output is designed for debuggability: - Each test ID clearly describes the mutation (e.g., "LegalPerson-missing-issuer") @@ -18,13 +21,13 @@ pytest tests/python/credentials/test_shacl_failures.py -v -k "missing_issuer" -Requires generated artifacts (``make generate``) and pyshacl. +Requires generated artifacts (``make generate``) and the OMB submodule. """ import copy import json import sys -import xml.etree.ElementTree as ET +import tempfile from dataclasses import dataclass from pathlib import Path from typing import Optional @@ -49,6 +52,7 @@ _GX_SHACL = ( _REPO_ROOT / "artifacts/harbour-gx-credential/harbour-gx-credential.shacl.ttl" ) +_ARTIFACTS_DIR = _REPO_ROOT / "artifacts" _EXAMPLES = _REPO_ROOT / "examples" # SHACL namespace for result graph queries @@ -67,7 +71,7 @@ ) _skip_no_omb = pytest.mark.skipif( - not (_OMB / "src" / "tools" / "utils" / "context_resolver.py").exists(), + not (_OMB / "src" / "tools" / "validators" / "shacl" / "validator.py").exists(), reason="ontology-management-base submodule not initialised", ) @@ -124,37 +128,29 @@ def _format_violations(violations: list[ShaclViolation]) -> str: # --------------------------------------------------------------------------- -# JSON-LD → RDF parsing with local context resolution +# OMB validation suite bootstrap # --------------------------------------------------------------------------- -def _build_context_url_map() -> dict[str, Path]: - """Build URL → local file mapping for offline JSON-LD parsing.""" +def _make_validator(): + """Create a ShaclValidator using the OMB validation suite. + + Registers harbour artifact directories so the resolver can discover + OWL ontologies, SHACL shapes, and JSON-LD contexts. Uses the default + ``rdfs`` inference mode — the same pipeline as production validation. + """ sys.path.insert(0, str(_OMB)) - from src.tools.utils.context_resolver import discover_context_files - - artifact_dirs = [ - _REPO_ROOT / "artifacts/harbour-core-credential", - _REPO_ROOT / "artifacts/harbour-gx-credential", - _REPO_ROOT / "artifacts/harbour-core-delegation", - _OMB / "artifacts/gx", - ] - url_map = discover_context_files(artifact_dirs) - - # Add OMB import contexts (W3C credentials/v2, status, did, schema.org) - catalog_path = _OMB / "imports" / "catalog-v001.xml" - if catalog_path.exists(): - tree = ET.parse(catalog_path) - cat_ns = {"c": "urn:oasis:names:tc:entity:xmlns:xml:catalog"} - for uri_elem in tree.getroot().findall(".//c:uri", cat_ns): - name = uri_elem.get("name", "") - val = uri_elem.get("uri", "") - if val.endswith(".context.jsonld"): - abs_path = (_OMB / "imports" / val).resolve() - if abs_path.exists(): - url_map[name] = abs_path - - return url_map + from src.tools.utils.registry_resolver import RegistryResolver + from src.tools.validators.shacl.validator import ShaclValidator + + resolver = RegistryResolver(_OMB) + resolver.register_artifact_directory(_ARTIFACTS_DIR) + return ShaclValidator( + root_dir=_OMB, + inference_mode="rdfs", + verbose=False, + resolver=resolver, + ) # --------------------------------------------------------------------------- @@ -163,17 +159,9 @@ def _build_context_url_map() -> dict[str, Path]: @pytest.fixture(scope="session") -def context_url_map(): - """URL → local file map for offline JSON-LD context resolution.""" - return _build_context_url_map() - - -@pytest.fixture(scope="session") -def shacl_graph(): - """Combined SHACL shapes graph (core + gx).""" - g = rdflib.Graph() - g.parse(str(_GX_SHACL), format="turtle") - return g +def shacl_validator(): + """OMB ShaclValidator with harbour artifacts registered.""" + return _make_validator() # --------------------------------------------------------------------------- @@ -183,32 +171,33 @@ def shacl_graph(): def _validate( credential: dict, - url_map: dict[str, Path], - shapes: rdflib.Graph, + validator, ) -> tuple[bool, list[ShaclViolation], str]: - """Validate a credential dict against SHACL shapes. + """Validate a credential dict via the OMB validation suite. + + Writes the credential to a temp file and runs the full ShaclValidator + pipeline (context inlining, schema discovery, RDFS inference, SHACL + validation) — identical to production ``make validate``. Returns: (conforms, violations, results_text) - - The results_text is the full pyshacl report — useful for debugging - when a test fails unexpectedly. """ - sys.path.insert(0, str(_OMB)) - import pyshacl - from src.tools.utils.context_resolver import inline_jsonld_with_local_contexts - - inlined_json = inline_jsonld_with_local_contexts(credential, url_map) - dg = rdflib.Graph() - dg.parse(data=inlined_json, format="json-ld") - - conforms, results_graph, results_text = pyshacl.validate( - data_graph=dg, - shacl_graph=shapes, - inference="none", - ) - violations = _extract_violations(results_graph) - return conforms, violations, results_text + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False, encoding="utf-8" + ) as f: + json.dump(credential, f, ensure_ascii=False) + temp_path = Path(f.name) + + try: + result = validator.validate([temp_path]) + violations = ( + _extract_violations(result.report_graph) + if result.report_graph is not None + else [] + ) + return result.conforms, violations, result.report_text + finally: + temp_path.unlink(missing_ok=True) # --------------------------------------------------------------------------- @@ -282,12 +271,10 @@ class TestPositiveBaseline: "TrustAnchorCredential-valid", ], ) - def test_valid_credential_conforms( - self, example_file, context_url_map, shacl_graph - ): + def test_valid_credential_conforms(self, example_file, shacl_validator): """A valid credential must pass SHACL validation with zero violations.""" cred = _load_example(example_file) - conforms, violations, text = _validate(cred, context_url_map, shacl_graph) + conforms, violations, text = _validate(cred, shacl_validator) assert conforms, ( f"Valid {example_file} should conform but got {len(violations)} " f"violation(s):\n{_format_violations(violations)}\n\n" @@ -421,13 +408,12 @@ def test_missing_field_detected( field_path, expected_path, test_id, - context_url_map, - shacl_graph, + shacl_validator, ): cred = _load_example(example_file) mutated = _remove_field(cred, *field_path) - conforms, violations, text = _validate(mutated, context_url_map, shacl_graph) + conforms, violations, text = _validate(mutated, shacl_validator) # Must not conform assert not conforms, ( @@ -524,13 +510,12 @@ def test_wrong_type_detected( mutate_fn, expected_constraint, test_id, - context_url_map, - shacl_graph, + shacl_validator, ): cred = _load_example(example_file) mutated = mutate_fn(cred) - conforms, violations, text = _validate(mutated, context_url_map, shacl_graph) + conforms, violations, text = _validate(mutated, shacl_validator) assert not conforms, ( f"[{test_id}] Credential with wrong type should FAIL " @@ -596,8 +581,7 @@ def test_unexpected_property_detected( field_name, field_value, test_id, - context_url_map, - shacl_graph, + shacl_validator, ): cred = _load_example(example_file) @@ -608,7 +592,7 @@ def test_unexpected_property_detected( else: mutated = _add_field(cred, field_name, field_value) - conforms, violations, text = _validate(mutated, context_url_map, shacl_graph) + conforms, violations, text = _validate(mutated, shacl_validator) assert not conforms, ( f"[{test_id}] Credential with unexpected property should FAIL " @@ -641,7 +625,7 @@ class TestCardinalityViolations: values must trigger a MaxCountConstraintComponent. """ - def test_multiple_issuers(self, context_url_map, shacl_graph): + def test_multiple_issuers(self, shacl_validator): """Two issuers should violate sh:maxCount 1.""" cred = _load_example("legal-person-credential.json") # JSON-LD doesn't naturally support duplicate keys, but we can @@ -651,13 +635,13 @@ def test_multiple_issuers(self, context_url_map, shacl_graph): ["did:ethr:0x14a34:0xaaaa", "did:ethr:0x14a34:0xbbbb"], "issuer", ) - conforms, violations, text = _validate(mutated, context_url_map, shacl_graph) + conforms, violations, text = _validate(mutated, shacl_validator) assert not conforms, ( "Two issuers should violate maxCount but SHACL said it conforms.\n" f"Full report:\n{text}" ) - def test_multiple_valid_from(self, context_url_map, shacl_graph): + def test_multiple_valid_from(self, shacl_validator): """Two validFrom dates should violate sh:maxCount 1.""" cred = _load_example("natural-person-credential.json") mutated = _set_field( @@ -665,13 +649,13 @@ def test_multiple_valid_from(self, context_url_map, shacl_graph): ["2025-01-15T00:00:00Z", "2025-06-01T00:00:00Z"], "validFrom", ) - conforms, violations, text = _validate(mutated, context_url_map, shacl_graph) + conforms, violations, text = _validate(mutated, shacl_validator) assert not conforms, ( "Two validFrom values should violate maxCount but SHACL said it conforms.\n" f"Full report:\n{text}" ) - def test_multiple_label_levels(self, context_url_map, shacl_graph): + def test_multiple_label_levels(self, shacl_validator): """Two labelLevel values should violate sh:maxCount 1.""" cred = _load_example("legal-person-credential.json") mutated = _set_field( @@ -680,7 +664,7 @@ def test_multiple_label_levels(self, context_url_map, shacl_graph): "credentialSubject", "harbour.gx:labelLevel", ) - conforms, violations, text = _validate(mutated, context_url_map, shacl_graph) + conforms, violations, text = _validate(mutated, shacl_validator) assert not conforms, ( "Two labelLevel values should violate maxCount " f"but SHACL said it conforms.\nFull report:\n{text}" From 0ff4763c1499456fd88bbdf589a3bf77fe226815 Mon Sep 17 00:00:00 2001 From: jdsika Date: Wed, 1 Apr 2026 10:14:03 +0200 Subject: [PATCH 62/78] fix(linkml): replace range: Any with spec-aligned types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - serviceEndpoint: range uri per DID Core §5.4 - transaction_data: range JsonLiteral per OID4VP §8.4 - Post-process JSON-LD contexts to use @json keyword - Add standards compliance rules to AGENTS.md The linkml:Any range caused SHACL closed-shape violations when RDFS inference added rdf:type linkml:Any to IRI objects. Replacing with spec-aligned ranges eliminates the issue. Signed-off-by: jdsika --- AGENTS.md | 27 ++++++++++++++++++++++++ linkml/harbour-core-credential.yaml | 22 +++++++++++++++---- src/python/harbour/generate_artifacts.py | 10 +++++++++ 3 files changed, 55 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1be5c40..ece9910 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,6 +127,33 @@ Brief description of the changes. Closes #42 ``` +## Standards Compliance + +**STRICT REQUIREMENT — all schemas, examples, and models must align with the +relevant W3C, IETF, and industry specifications.** + +When defining or modifying LinkML schemas, JSON-LD examples, or DID documents: + +1. **Cross-reference the spec copy in `docs/`.** The LinkML schema files use + bracketed tags (e.g. `[VCDM2]`, `[DID Core]`, `[OID4VP]`, `[SD-JWT]`) that + cite specific spec sections. Before changing a slot range, class hierarchy, + or property definition, locate the corresponding spec document in `docs/` + and verify the modeling choice against the normative text. +2. **Document the rationale in the schema.** Every non-trivial modeling + decision must have a YAML comment citing the spec section and briefly + explaining *why* the chosen type/range/constraint is correct. +3. **Use standard vocabulary** (DID Core, VC Data Model 2.0, OID4VP, schema.org, + Gaia-X Trust Framework) rather than inventing new terms. +4. **Never use `range: Any`** in LinkML slot definitions. `linkml:Any` produces + `rdfs:range linkml:Any` in OWL, which triggers closed-shape SHACL violations + during RDFS inference. Always choose a spec-aligned range: + - `uri` for properties whose values are network addresses or identifiers + (e.g. DID Core `serviceEndpoint`) + - A named class for structured objects with a defined schema + (e.g. OID4VP `TransactionData`) +5. **Validate examples against SHACL** (`make validate`) to catch inference + issues before they reach CI. + ## Coding Style ### Python diff --git a/linkml/harbour-core-credential.yaml b/linkml/harbour-core-credential.yaml index ba38d76..f9fbbe8 100644 --- a/linkml/harbour-core-credential.yaml +++ b/linkml/harbour-core-credential.yaml @@ -136,11 +136,16 @@ slots: slot_uri: sec:publicKeyJwk range: JsonLiteral - # [DID-CORE] §5.4 — serviceEndpoint can be a URI, map, or set. - # Each service entry MUST have id, type, and serviceEndpoint. + # [DID-CORE] §5.4 — serviceEndpoint is a URI, map, or set thereof. + # The common case is a plain URI (network address); service-specific classes + # override via slot_usage for structured endpoint objects (e.g. + # OrganizationEndpoint, CRSetServiceEndpoint). + # Base range is uri to avoid rdfs:range linkml:Any in OWL, which would + # cause SHACL closed-shape violations via RDFS inference on any IRI-valued + # endpoint node. serviceEndpoint: slot_uri: didcore:serviceEndpoint - range: Any + range: uri required: true # --- Delegated Signing Evidence Slots --- @@ -219,14 +224,23 @@ slots: # nonce, iat, txn. Hash computed over canonical JSON (sorted keys, no whitespace). # NOTE: OID4VP hashes base64url transport string; Harbour hashes decoded canonical # JSON. Both serve different layers (transport binding vs content integrity). + # + # Modeled as rdf:JSON literal (not a typed class) because: + # 1. OID4VP §5.1 transmits it as base64url-encoded JSON — a blob at transport layer + # 2. Harbour Delegation Spec §3.6 hashes canonical JSON — exact key preservation + # required, which conflicts with RDF property expansion + # 3. Internal structure (type, credential_ids, nonce, iat, txn) is profile-specific + # and varies by action type; the txn sub-object is fully profile-defined + # 4. Matches the publicKeyJwk pattern (also rdf:JSON) for structured JSON content transaction_data: description: > OID4VP-aligned transaction data object (§8.4). Contains action type, credential IDs, timestamps, and action-specific details in the txn field. On the receipt SD-JWT-VC this is a selectively disclosable claim enabling three-layer privacy (public / authorized / full audit). + Serialized as an rdf:JSON literal to preserve canonical structure for hashing. slot_uri: harbour:transaction_data - range: Any + range: JsonLiteral required: false # [OID4VP] — conceptually maps to client_id → KB-JWT aud. diff --git a/src/python/harbour/generate_artifacts.py b/src/python/harbour/generate_artifacts.py index d0d2477..d754234 100644 --- a/src/python/harbour/generate_artifacts.py +++ b/src/python/harbour/generate_artifacts.py @@ -116,6 +116,16 @@ def main() -> None: if isinstance(ctx_obj, dict) and "type" not in ctx_obj: ctx_obj["type"] = "@type" + # Fix rdf:JSON → @json in context entries. + # LinkML generates "@type": "rdf:JSON" for JsonLiteral ranges, but + # JSON-LD processors require the "@json" keyword to parse values as + # JSON literals. Without this fix, rdflib expands the value as a + # blank node instead of an rdf:JSON literal. + if isinstance(ctx_obj, dict): + for key, val in ctx_obj.items(): + if isinstance(val, dict) and val.get("@type") == "rdf:JSON": + val["@type"] = "@json" + ctx_data["@context"] = ctx_obj ctx_text = json.dumps(ctx_data, indent=3, ensure_ascii=False) From 7a7e41e9babb283ccda9ecac9f2d511cd80410d8 Mon Sep 17 00:00:00 2001 From: jdsika Date: Wed, 1 Apr 2026 12:36:57 +0200 Subject: [PATCH 63/78] fix(tests): use temp files for cross-runtime interop on Windows On Windows, yarn.cmd invokes cmd.exe which mangles JS code containing braces and parentheses passed via -e flag. Write scripts to temp .mjs files instead, and resolve yarn via shutil.which() for cross-platform subprocess compatibility. Signed-off-by: jdsika --- tests/interop/test_cross_runtime.py | 46 ++++++++++++++++------------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/tests/interop/test_cross_runtime.py b/tests/interop/test_cross_runtime.py index 7ce4a9c..8b5884c 100644 --- a/tests/interop/test_cross_runtime.py +++ b/tests/interop/test_cross_runtime.py @@ -1,7 +1,9 @@ """Cross-runtime interop tests: Python signs → Node.js verifies (and vice versa).""" import json +import shutil import subprocess +import tempfile from pathlib import Path import pytest @@ -21,42 +23,46 @@ TS_DIR = Path(__file__).resolve().parents[2] / "src" / "typescript" / "harbour" -def _can_run_node_jose() -> bool: - """Check whether yarn-managed Node can import jose in the TS workspace.""" +_YARN = shutil.which("yarn") or "yarn" + + +def _run_node(script: str) -> str: + """Run a Node.js ESM script via a temp file (avoids cmd.exe arg mangling on Windows).""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".mjs", dir=str(TS_DIR), delete=False, + ) as f: + f.write(script) + tmp = Path(f.name) try: result = subprocess.run( - ["yarn", "node", "--input-type=module", "-e", 'import "jose";'], + [_YARN, "node", str(tmp)], capture_output=True, text=True, cwd=str(TS_DIR), timeout=30, ) - return result.returncode == 0 - except (FileNotFoundError, subprocess.TimeoutExpired, OSError): + if result.returncode != 0: + raise RuntimeError(f"Node.js error:\n{result.stderr}") + return result.stdout.strip() + finally: + tmp.unlink(missing_ok=True) + + +def _can_run_node_jose() -> bool: + """Check whether yarn-managed Node can import jose in the TS workspace.""" + try: + return _run_node('import "jose"; console.log("OK");') == "OK" + except (FileNotFoundError, subprocess.TimeoutExpired, OSError, RuntimeError): return False # Skip if TypeScript runtime dependencies are unavailable pytestmark = pytest.mark.skipif( not _can_run_node_jose(), - reason="TypeScript runtime dependencies unavailable (run 'make ts-bootstrap').", + reason="TypeScript runtime dependencies unavailable (run 'make setup ts').", ) -def _run_node(script: str) -> str: - """Run a Node.js script and return its stdout.""" - result = subprocess.run( - ["yarn", "node", "--input-type=module", "-e", script], - capture_output=True, - text=True, - cwd=str(TS_DIR), - timeout=30, - ) - if result.returncode != 0: - raise RuntimeError(f"Node.js error:\n{result.stderr}") - return result.stdout.strip() - - class TestPythonSignNodeVerify: """Python signs a VC/VP → Node.js verifies it.""" From b542ef436a2727d34e1e7ce179174dbc8d2aa3b4 Mon Sep 17 00:00:00 2001 From: jdsika Date: Wed, 1 Apr 2026 19:23:45 +0200 Subject: [PATCH 64/78] chore: bump OMB submodule with normalize-prefixes fix Signed-off-by: jdsika --- submodules/ontology-management-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index 3707f62..d4522e3 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit 3707f62b8c960e84e3e8a7a487cf4d4751390861 +Subproject commit d4522e3cf2e62778d9504be118a6e9a11c589066 From 63d45c4a42ea35450646ca69f63f1feb16f652ef Mon Sep 17 00:00:00 2001 From: jdsika Date: Wed, 1 Apr 2026 22:29:24 +0200 Subject: [PATCH 65/78] chore: bump OMB submodule (deterministic pipeline + prefix fixes) Signed-off-by: jdsika --- submodules/ontology-management-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index d4522e3..c9ec215 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit d4522e3cf2e62778d9504be118a6e9a11c589066 +Subproject commit c9ec215af88cc49a9c43017327980b5bd6c93b29 From 99716f1e73ca5f6ecec187d635ed97b6c90af83a Mon Sep 17 00:00:00 2001 From: jdsika Date: Thu, 2 Apr 2026 11:14:29 +0200 Subject: [PATCH 66/78] chore: bump OMB submodule (sorted boolean expressions + review fixes) Signed-off-by: jdsika --- submodules/ontology-management-base | 2 +- tests/interop/test_cross_runtime.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index c9ec215..b26f6fd 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit c9ec215af88cc49a9c43017327980b5bd6c93b29 +Subproject commit b26f6fde9c6e5ab4c2e36788517d18de7477e2e1 diff --git a/tests/interop/test_cross_runtime.py b/tests/interop/test_cross_runtime.py index 8b5884c..3557e31 100644 --- a/tests/interop/test_cross_runtime.py +++ b/tests/interop/test_cross_runtime.py @@ -29,7 +29,10 @@ def _run_node(script: str) -> str: """Run a Node.js ESM script via a temp file (avoids cmd.exe arg mangling on Windows).""" with tempfile.NamedTemporaryFile( - mode="w", suffix=".mjs", dir=str(TS_DIR), delete=False, + mode="w", + suffix=".mjs", + dir=str(TS_DIR), + delete=False, ) as f: f.write(script) tmp = Path(f.name) From be5c4e9f506be4b2532dd566281d2cdae898744b Mon Sep 17 00:00:00 2001 From: jdsika Date: Thu, 2 Apr 2026 12:52:15 +0200 Subject: [PATCH 67/78] chore: bump OMB submodule (rebuilt combined branch) Signed-off-by: jdsika --- submodules/ontology-management-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index b26f6fd..53f7a19 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit b26f6fde9c6e5ab4c2e36788517d18de7477e2e1 +Subproject commit 53f7a195ea06a9915ea8fa698a1c34ab5f1d4d06 From 72c1eab81abf5ca048222fa52f24d10ff9563328 Mon Sep 17 00:00:00 2001 From: jdsika Date: Thu, 2 Apr 2026 18:02:46 +0200 Subject: [PATCH 68/78] chore: pin OMB to linkml develop branch Signed-off-by: jdsika --- submodules/ontology-management-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index 53f7a19..0398c96 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit 53f7a195ea06a9915ea8fa698a1c34ab5f1d4d06 +Subproject commit 0398c9636abf688739a6aa7b515a83651008f61a From 132b8bd5ee074525c0039ecb589561530220bbb2 Mon Sep 17 00:00:00 2001 From: jdsika Date: Thu, 2 Apr 2026 21:29:26 +0200 Subject: [PATCH 69/78] fix: align test targets, add lint hooks, and add check target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - check_dev_setup: verify both linkml AND harbour are importable (was only checking linkml — missed harbour installation issues) - _test_default: exclude tests/interop/ (requires TypeScript built; use 'make test full' for all including interop) - Add check target: generate + validate (matches simpulseid pattern) - Update all target: lint + check + test (was lint + test only) - Add jsonld-lint and turtle-lint pre-commit hooks for JSON-LD and Turtle syntax validation (matching OMB and simpulseid patterns) Signed-off-by: jdsika Signed-off-by: jdsika --- .pre-commit-config.yaml | 14 ++++++++++++++ Makefile | 19 +++++++++++++------ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d423978..ecf9695 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,6 +16,20 @@ repos: types: [python] pass_filenames: true + - id: jsonld-lint + name: JSON-LD Parser + entry: python -c "import json, sys; [json.load(open(f)) for f in sys.argv[1:]]" + language: system + files: \.(json|jsonld)$ + pass_filenames: true + + - id: turtle-lint + name: Turtle Parser + entry: python -c "import sys; from rdflib import Graph; [Graph().parse(f, format='turtle') for f in sys.argv[1:]]" + language: system + files: \.ttl$ + pass_filenames: true + - repo: https://github.com/DavidAnson/markdownlint-cli2 rev: v0.17.2 hooks: diff --git a/Makefile b/Makefile index 83b54cc..753ec07 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # Harbour Credentials Makefile # ============================ -.PHONY: setup install generate validate lint format test build story all clean help \ +.PHONY: setup install generate validate lint format test build story check all clean help \ release-artifacts \ _help_general _help_setup _help_install _help_validate _help_lint _help_format _help_test _help_story _help_build \ _setup_default _setup_submodules _setup_ts _install_default _install_dev \ @@ -84,7 +84,7 @@ define check_dev_setup echo ""; \ exit 1; \ fi - @if ! "$(PYTHON)" -c "import linkml" 2>/dev/null; then \ + @if ! "$(PYTHON)" -c "import linkml, harbour" 2>/dev/null; then \ echo ""; \ echo "ERROR: Dev dependencies not installed."; \ echo ""; \ @@ -495,8 +495,8 @@ test: _test_default: $(call check_dev_setup) - @echo "Running Python tests..." - @PYTHONPATH="src/python$(PYTHONPATH_SEP)$$PYTHONPATH" $(PYTEST) tests/ -v + @echo "Running Python tests (excluding interop — use 'make test full' for all)..." + @PYTHONPATH="src/python$(PYTHONPATH_SEP)$$PYTHONPATH" $(PYTEST) tests/ -v --ignore=tests/interop @echo "OK: Python tests complete" # Run tests with coverage @@ -600,11 +600,18 @@ release-artifacts: ## Copy artifacts to w3id directory structure for GitHub Page @echo "OK: Artifacts prepared in $(RELEASE_DIR)/" # Compound targets +check: + @echo "Running check pipeline (generate + validate)..." + @"$(MAKE)" --no-print-directory generate + @"$(MAKE)" --no-print-directory validate + @echo "OK: Check pipeline complete" + all: - @echo "Running default quality pipeline (lint + test)..." + @echo "Running full quality pipeline (lint + check + test)..." @"$(MAKE)" --no-print-directory lint + @"$(MAKE)" --no-print-directory check @"$(MAKE)" --no-print-directory test - @echo "OK: Default quality pipeline complete" + @echo "OK: Full quality pipeline complete" # Run all tests (Python + TypeScript) _test_all: From 21c1b7ef481971535c3f85b24d78db65753cf5cd Mon Sep 17 00:00:00 2001 From: jdsika Date: Thu, 2 Apr 2026 21:40:46 +0200 Subject: [PATCH 70/78] ci: add OS and Python version matrix to all non-lint jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test across OS: ubuntu-latest, macos-latest, windows-latest Matrix applied to: - test-python: 3 OS × 2 Python (3.12, 3.13) = 6 jobs - test-ts: 3 OS = 3 jobs - test-interop: 3 OS = 3 jobs - generate-validate: 3 OS = 3 jobs Lint jobs remain ubuntu-only (formatting is OS-independent). Signed-off-by: jdsika Signed-off-by: jdsika --- .github/workflows/ci.yml | 35 ++++++++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87c826d..5d81536 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,8 +56,12 @@ jobs: run: make lint ts generate-validate: - name: Generate & Validate - runs-on: ubuntu-latest + name: Generate & Validate (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 with: @@ -83,14 +87,19 @@ jobs: run: make validate shacl test-python: - name: Test (Python) - runs-on: ubuntu-latest + name: Test (Python) (${{ matrix.os }}, py${{ matrix.python-version }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.12", "3.13"] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} cache: 'pip' - name: Install dependencies @@ -100,8 +109,12 @@ jobs: run: make test test-ts: - name: Test (TypeScript) - runs-on: ubuntu-latest + name: Test (TypeScript) (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -113,8 +126,12 @@ jobs: run: make test ts test-interop: - name: Cross-Runtime Interop - runs-on: ubuntu-latest + name: Cross-Runtime Interop (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} needs: [test-python, test-ts] steps: - uses: actions/checkout@v4 From c836fdbc8f3ab4a50b4d90684867b05887529806 Mon Sep 17 00:00:00 2001 From: jdsika Date: Thu, 2 Apr 2026 22:02:49 +0200 Subject: [PATCH 71/78] ci: add credential lifecycle story job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New 'Credential Story' job runs the full generate → sign → verify → SHACL validate pipeline across 3 OS (ubuntu/macos/windows). Signed-off-by: jdsika Signed-off-by: jdsika --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d81536..21f2c87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,3 +153,30 @@ jobs: - name: Run interop tests run: make test interop + + # --- Credential Lifecycle Story --- + story: + name: Credential Story (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + needs: [generate-validate] + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: 'pip' + + - name: Install dependencies and submodules + run: | + make install dev + make setup submodules + + - name: Run credential storyline (generate → sign → verify → SHACL validate) + run: make story From 2f387f8f3a3e1a9e3fe586f9dfc29b771b77ea5e Mon Sep 17 00:00:00 2001 From: jdsika Date: Thu, 2 Apr 2026 22:17:51 +0200 Subject: [PATCH 72/78] fix: set PYTHONIOENCODING=utf-8 for story targets on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verify_signed_examples.py script uses Unicode checkmarks (✓) in print output. On Windows, the default cp1252 encoding cannot encode these characters. Setting PYTHONIOENCODING=utf-8 fixes this. OMB's validators use emojis too but run via pytest which handles encoding internally. Story targets run scripts directly, so they need the explicit encoding override. Closes #5 Signed-off-by: jdsika Signed-off-by: jdsika --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 753ec07..798b616 100644 --- a/Makefile +++ b/Makefile @@ -563,13 +563,13 @@ _story_sign: $(call check_dev_setup) @echo "Signing Harbour example storylines..." @rm -rf examples/signed examples/gaiax/signed - @PYTHONPATH="src/python$(PYTHONPATH_SEP)$$PYTHONPATH" "$(PYTHON)" -m credentials.example_signer examples/ + @PYTHONIOENCODING=utf-8 PYTHONPATH="src/python$(PYTHONPATH_SEP)$$PYTHONPATH" "$(PYTHON)" -m credentials.example_signer examples/ @echo "OK: Signed example artifacts written to ignored signed/ directories" _story_verify: $(call check_dev_setup) @echo "Verifying Harbour signed example storylines..." - @PYTHONPATH="src/python$(PYTHONPATH_SEP)$$PYTHONPATH" "$(PYTHON)" -m credentials.verify_signed_examples + @PYTHONIOENCODING=utf-8 PYTHONPATH="src/python$(PYTHONPATH_SEP)$$PYTHONPATH" "$(PYTHON)" -m credentials.verify_signed_examples @echo "OK: Signed Harbour example artifacts verified" _story_default: From 8260d1310678fd35435ce687505726a5441a40c9 Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 3 Apr 2026 08:46:18 +0200 Subject: [PATCH 73/78] fix(linkml): add missing W3C DID Core properties to DIDDocument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DIDDocument class only modeled 5 of 10 W3C DID Core properties. The generated SHACL shape (sh:closed true) rejected spec-compliant optional properties like capabilityDelegation. Added per W3C DID Core (https://www.w3.org/TR/did-core/): - keyAgreement (§5.3.3) → sec:keyAgreementMethod - capabilityInvocation (§5.3.4) → sec:capabilityInvocationMethod - capabilityDelegation (§5.3.5) → sec:capabilityDelegationMethod - alsoKnownAs (§5.1.3) → as:alsoKnownAs URI suffixes follow the DID Core JSON-LD context convention (imports/did/did.context.jsonld) where verification relationships expand to sec:*Method URIs. Also fixes harbour-signing-service.did.json: replaced undefined didcore:serviceEndpoint prefix with spec-compliant serviceEndpoint. Signed-off-by: jdsika Signed-off-by: jdsika --- .../did-ethr/harbour-signing-service.did.json | 4 +- linkml/harbour-core-credential.yaml | 46 +++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/examples/did-ethr/harbour-signing-service.did.json b/examples/did-ethr/harbour-signing-service.did.json index 036f554..92b0275 100644 --- a/examples/did-ethr/harbour-signing-service.did.json +++ b/examples/did-ethr/harbour-signing-service.did.json @@ -48,7 +48,7 @@ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#service-1", "type": "harbour:TrustAnchorService", - "didcore:serviceEndpoint": { + "serviceEndpoint": { "type": "schema:Organization", "name": "Haven Trust Anchor", "url": "https://resolver.harbour.id/trust-anchors/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" @@ -57,7 +57,7 @@ { "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#service-2", "type": "harbour:CRSetRevocationRegistryService", - "didcore:serviceEndpoint": { + "serviceEndpoint": { "type": "harbour:CRSetServiceEndpoint", "registryEndpoint": "https://resolver.harbour.id/crset/" } diff --git a/linkml/harbour-core-credential.yaml b/linkml/harbour-core-credential.yaml index f9fbbe8..50185a8 100644 --- a/linkml/harbour-core-credential.yaml +++ b/linkml/harbour-core-credential.yaml @@ -129,6 +129,32 @@ slots: slot_uri: sec:assertionMethod range: uri + # [DID-CORE §5.3.3] did/v1 expands keyAgreement to sec:keyAgreementMethod. + # Cryptographic public keys for key agreement protocols (e.g. ECDH). + keyAgreement: + slot_uri: sec:keyAgreementMethod + range: uri + + # [DID-CORE §5.3.4] did/v1 expands capabilityInvocation to + # sec:capabilityInvocationMethod. Verification methods the DID subject + # can use to invoke cryptographic capabilities. + capabilityInvocation: + slot_uri: sec:capabilityInvocationMethod + range: uri + + # [DID-CORE §5.3.5] did/v1 expands capabilityDelegation to + # sec:capabilityDelegationMethod. Verification methods the DID subject + # can use to delegate cryptographic capabilities to another party. + capabilityDelegation: + slot_uri: sec:capabilityDelegationMethod + range: uri + + # [DID-CORE §5.1.3] Alternative identifiers for the DID subject. + # URI from the ActivityStreams vocabulary (W3C DID Core reuses this). + alsoKnownAs: + slot_uri: https://www.w3.org/ns/activitystreams#alsoKnownAs + range: uri + # [DID-CORE] §5.3.1 — publicKeyJwk carries verification method key material # encoded as a JSON Web Key (RFC 7517). In JSON-LD it is represented as # an rdf:JSON literal when the term is typed with @json. @@ -271,6 +297,26 @@ classes: slot_uri: sec:assertionMethod multivalued: true range: uri + # [DID-CORE §5.3.3] Key agreement verification methods (e.g. ECDH). + keyAgreement: + slot_uri: sec:keyAgreementMethod + multivalued: true + range: uri + # [DID-CORE §5.3.4] Capability invocation verification methods. + capabilityInvocation: + slot_uri: sec:capabilityInvocationMethod + multivalued: true + range: uri + # [DID-CORE §5.3.5] Capability delegation verification methods. + capabilityDelegation: + slot_uri: sec:capabilityDelegationMethod + multivalued: true + range: uri + # [DID-CORE §5.1.3] Alternative identifiers for the DID subject. + alsoKnownAs: + slot_uri: https://www.w3.org/ns/activitystreams#alsoKnownAs + multivalued: true + range: uri # [DID-CORE] §5.4 — service is OPTIONAL, each entry MUST have id, type, # and serviceEndpoint. service values MUST be unique. service: From 37e0b2afec7f6707670d3619285262a6fa58e0cc Mon Sep 17 00:00:00 2001 From: jdsika Date: Sat, 4 Apr 2026 10:01:16 +0200 Subject: [PATCH 74/78] chore: pin OMB to v0.1.6 Pin ontology-management-base submodule to the v0.1.6 release which includes CI artifact generation verification, cross-platform test matrix, and Makefile CI detection fixes. Signed-off-by: jdsika --- submodules/ontology-management-base | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/ontology-management-base b/submodules/ontology-management-base index 0398c96..1b9226e 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit 0398c9636abf688739a6aa7b515a83651008f61a +Subproject commit 1b9226e689848a1f15725d6b83b028f97dae8ada From 2833cb2b7b6f010a4f40dc293fa714f23e33547a Mon Sep 17 00:00:00 2001 From: jdsika Date: Sat, 4 Apr 2026 11:37:13 +0200 Subject: [PATCH 75/78] chore: sync w3id.org fork with upstream master Update w3id.org submodule to upstream master (ecada1d3). The reachhaven/ and simpulse-id/ namespace redirect rules have been merged upstream. Signed-off-by: jdsika --- submodules/w3id.org | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/w3id.org b/submodules/w3id.org index 4ac1b00..ecada1d 160000 --- a/submodules/w3id.org +++ b/submodules/w3id.org @@ -1 +1 @@ -Subproject commit 4ac1b00ec371020430e070a3babf08e295cf0a97 +Subproject commit ecada1d395bdae0899c54c2676b339f615cfb949 From c569ec88cbd57094196b904ea6890d0c1320042b Mon Sep 17 00:00:00 2001 From: jdsika Date: Sat, 4 Apr 2026 12:50:11 +0200 Subject: [PATCH 76/78] fix: use @type instead of type in serviceEndpoint objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standardize JSON-LD type declarations in DID service endpoints. Inside serviceEndpoint objects, @type is needed for proper JSON-LD expansion — plain type is only aliased at the DID document level by the DID Core context. Signed-off-by: jdsika --- examples/did-ethr/harbour-signing-service.did.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/did-ethr/harbour-signing-service.did.json b/examples/did-ethr/harbour-signing-service.did.json index 92b0275..c4e656b 100644 --- a/examples/did-ethr/harbour-signing-service.did.json +++ b/examples/did-ethr/harbour-signing-service.did.json @@ -49,7 +49,7 @@ "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#service-1", "type": "harbour:TrustAnchorService", "serviceEndpoint": { - "type": "schema:Organization", + "@type": "schema:Organization", "name": "Haven Trust Anchor", "url": "https://resolver.harbour.id/trust-anchors/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774" } @@ -58,7 +58,7 @@ "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#service-2", "type": "harbour:CRSetRevocationRegistryService", "serviceEndpoint": { - "type": "harbour:CRSetServiceEndpoint", + "@type": "harbour:CRSetServiceEndpoint", "registryEndpoint": "https://resolver.harbour.id/crset/" } } From de4c3ccfd5696ea5419bde14544c099489413b3f Mon Sep 17 00:00:00 2001 From: jdsika Date: Sat, 4 Apr 2026 13:28:23 +0200 Subject: [PATCH 77/78] chore: pin w3id.org to upstream master with merged redirects Update w3id.org fork to latest upstream master (d004a453). Both reachhaven/harbour and ascs-ev/simpulse-id namespace redirect rules are now merged in the public w3id.org repository. Signed-off-by: jdsika --- submodules/w3id.org | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/w3id.org b/submodules/w3id.org index ecada1d..d004a45 160000 --- a/submodules/w3id.org +++ b/submodules/w3id.org @@ -1 +1 @@ -Subproject commit ecada1d395bdae0899c54c2676b339f615cfb949 +Subproject commit d004a45366f3b1dd6716b482c0b54fc5ecd6cdc1 From 9943c17117def0c8e652414d3e97002d95f012c5 Mon Sep 17 00:00:00 2001 From: jdsika Date: Sat, 4 Apr 2026 14:01:03 +0200 Subject: [PATCH 78/78] feat: add revocation warning and transaction freshness to VP verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per EVES-009 §5 security requirements: - Log warning when credentialStatus is present but revocation check is not performed (caller responsibility per spec) - Add optional check_transaction_freshness parameter to verify_sd_jwt_vp() that validates transaction data timestamps (iat, exp) via validate_transaction_data() - Default off for backwards compatibility; callers opt in with check_transaction_freshness=True Signed-off-by: jdsika --- src/python/harbour/sd_jwt_vp.py | 37 ++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/python/harbour/sd_jwt_vp.py b/src/python/harbour/sd_jwt_vp.py index 305a3f5..58425c2 100644 --- a/src/python/harbour/sd_jwt_vp.py +++ b/src/python/harbour/sd_jwt_vp.py @@ -25,6 +25,7 @@ import base64 import hashlib import json +import logging import sys import time from copy import deepcopy @@ -42,10 +43,13 @@ TransactionData, compute_transaction_data_param_hash, create_delegation_challenge, + validate_transaction_data, ) from harbour.keys import PrivateKey, PublicKeyType from harbour.verifier import VerificationError +logger = logging.getLogger(__name__) + # SD-JWT uses ~-delimited format SD_JWT_SEPARATOR = "~" DELEGATED_EVIDENCE_TYPES = { @@ -340,6 +344,8 @@ def verify_sd_jwt_vp( *, expected_nonce: str | None = None, expected_audience: str | None = None, + check_transaction_freshness: bool = False, + max_transaction_age_seconds: int = 300, ) -> dict: """Verify an SD-JWT VP and return disclosed claims and evidence. @@ -349,6 +355,11 @@ def verify_sd_jwt_vp( holder_public_key: Holder's public key (for VP and KB-JWT verification). expected_nonce: If provided, verify nonce matches. expected_audience: If provided, verify audience matches. + check_transaction_freshness: If True, validate transaction data + timestamps (iat, exp) per EVES-009 §5 time-bounding requirement. + max_transaction_age_seconds: Maximum age of transaction data in + seconds (default: 300). Only used when check_transaction_freshness + is True. Returns: dict with: @@ -517,7 +528,31 @@ def verify_sd_jwt_vp( if kb_audience != expected_audience: raise VerificationError("Audience mismatch in KB-JWT") - # 8. Process disclosures + # 8a. Credential status check (EVES-009 §5: credential freshness) + credential_status = vc_payload.get("credentialStatus") + if credential_status is not None: + logger.warning( + "Credential contains credentialStatus but revocation check is not " + "performed by verify_sd_jwt_vp(). Caller SHOULD verify revocation " + "status independently per EVES-009 §5." + ) + + # 8b. Transaction freshness check (EVES-009 §5: time-bounding) + if check_transaction_freshness and evidence_list: + for ev_item in evidence_list: + tx_data_raw = ev_item.get("transaction_data") + if tx_data_raw and isinstance(tx_data_raw, dict): + try: + tx = TransactionData(**tx_data_raw) + validate_transaction_data( + tx, max_age_seconds=max_transaction_age_seconds + ) + except Exception as e: + raise VerificationError( + f"Transaction data freshness check failed: {e}" + ) from e + + # 8c. Process disclosures sd_digests = set(vc_payload.get("_sd", [])) disclosed_claims = { k: v for k, v in vc_payload.items() if k not in ("_sd", "_sd_alg")