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/.github/copilot-instructions.md b/.github/copilot-instructions.md index d2c179b..9711606 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,37 +7,38 @@ 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 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 @@ -47,7 +48,7 @@ Read these BEFORE making changes: ## Project Structure -``` +```text src/ ├── python/ │ ├── harbour/ # Crypto library (keys, sign, verify, sd-jwt, kb-jwt, x509) @@ -102,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: @@ -110,7 +114,9 @@ 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 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 @@ -119,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/.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 fd73ddf..21f2c87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,13 +24,13 @@ 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) + lint-markdown: + name: Lint (Markdown) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -39,20 +39,29 @@ jobs: with: node-version: "22" - - name: Enable Corepack - run: corepack enable + - name: Lint Markdown + run: npx --yes markdownlint-cli2 - - name: Install dependencies - working-directory: src/typescript/harbour - run: yarn install --immutable + lint-ts: + name: Lint (TypeScript) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 - - name: Type check - working-directory: src/typescript/harbour - run: yarn lint + - uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Lint TypeScript + 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: @@ -63,10 +72,10 @@ jobs: python-version: "3.12" cache: 'pip' - - name: Install dependencies + - name: Install dependencies and submodules run: | - python3 -m pip install -e ".[dev]" linkml - python3 -m pip install -e "./submodules/ontology-management-base" + make install dev + make setup submodules - name: Generate artifacts run: make generate @@ -75,28 +84,37 @@ jobs: run: make validate - name: Validate examples (SHACL conformance) - run: make validate-shacl + 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 - 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) - 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 @@ -104,20 +122,16 @@ jobs: with: node-version: "22" - - 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 - 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 @@ -131,15 +145,38 @@ jobs: with: node-version: "22" - - name: Enable Corepack - run: corepack enable - - name: Install Python dependencies - run: python3 -m pip install -e ".[dev]" + run: make install dev - - name: Install TypeScript dependencies - working-directory: src/typescript/harbour - run: yarn install --immutable + - name: Build TypeScript + 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 + + # --- 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 diff --git a/.gitignore b/.gitignore index b980931..22b7491 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ __pycache__/ htmlcov/ artifacts/*/ examples/signed/ +examples/gaiax/signed/ dist/ .venv/ @@ -28,3 +29,4 @@ node_modules/ # Agent workspace (untracked) /.playground/ +site/ 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/.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..ecf9695 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,26 +2,36 @@ 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 + - 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 - types: [python] - args: ["--config=.flake8"] + 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: + - id: markdownlint-cli2 + args: ["--config", ".markdownlint-cli2.yaml"] diff --git a/AGENTS.md b/AGENTS.md index f87e328..ece9910 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,11 +8,11 @@ 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 -``` +```text src/ ├── python/ │ ├── harbour/ # Crypto library (sign, verify, keys, sd-jwt, kb-jwt, x509) @@ -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 @@ -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: @@ -78,7 +81,9 @@ 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 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 @@ -114,17 +119,45 @@ 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 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 + - Python 3.12+ with type hints on public APIs - Use `pathlib.Path` (not `os.path`) - 4-space indentation @@ -132,10 +165,11 @@ Closes #42 - Run `make lint` 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 +- Run `make lint ts` before committing ## Module CLI Requirements diff --git a/CLAUDE.md b/CLAUDE.md index 988a3a8..225323b 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 @@ -65,12 +65,15 @@ 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 | ### 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 @@ -80,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` @@ -107,6 +110,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,22 +123,26 @@ import { signJwt, verifyJwt, issueSdJwt, verifySdJwt, createKbJwt, verifyKbJwt, + createDelegationChallenge, verifyChallenge, createTransactionData, + issueSdJwtVp, verifySdJwtVp, } from '@reachhaven/harbour-credentials'; ``` ## 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 ### 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 + - **TypeScript 5.x** with strict mode, ES2022 target - **async/await** for crypto operations - Export types alongside functions @@ -153,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 | |------|---------| @@ -166,7 +178,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/Makefile b/Makefile index eecd2f8..798b616 100644 --- a/Makefile +++ b/Makefile @@ -1,46 +1,92 @@ # 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 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 \ + _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 +LINKML_SUBMODULE_DIR := $(OMB_SUBMODULE_DIR)/submodules/linkml/packages/linkml -# 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 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 "❌ Development environment not set up."; \ + echo "ERROR: Development environment not set up."; \ echo ""; \ echo "Please run first:"; \ echo " make 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 "❌ Dev dependencies not installed."; \ + echo "ERROR: Dev dependencies not installed."; \ echo ""; \ echo "Please run:"; \ echo " make setup"; \ @@ -51,223 +97,542 @@ endef # LinkML schema files LINKML_SCHEMAS := $(wildcard linkml/*.yaml) -DOMAINS := harbour core gaiax-domain -ifdef CI - GEN_OWL := gen-owl - GEN_SHACL := gen-shacl - GEN_JSONLD_CONTEXT := gen-jsonld-context +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 := 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)),) +.PHONY: $(SUBCOMMAND_GOALS) + +$(SUBCOMMAND_GOALS): + @: else - GEN_OWL := $(VENV)/bin/gen-owl - GEN_SHACL := $(VENV)/bin/gen-shacl - GEN_JSONLD_CONTEXT := $(VENV)/bin/gen-jsonld-context +help: + @"$(MAKE)" --no-print-directory _help_general endif # Default target -help: - @echo "🔧 Showing available commands..." +_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 ts-bootstrap - Enable corepack and install TypeScript dependencies" + @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 "" @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 help - Show validate subcommands" @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 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-all - Run all tests (Python + TypeScript)" - @echo " make test-cov - Run Python tests with coverage report" + @echo " make test - Run Python pytest suite" + @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" - @echo "" - @echo "✅ Help displayed" + @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)" + @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" + @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:" + @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..." - @echo "🔧 Checking Python virtual environment and dependencies..." + @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 "$(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]"; \ + $(PRECOMMIT) install; \ + fi +else @set -e; \ - if [ ! -x "$(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 \ - echo "✅ Python virtual environment and dependencies are ready at $(VENV)"; \ + if [ ! -f "$(PYTHON)" ]; then \ + echo "Python virtual environment not found; bootstrapping..."; \ + "$(MAKE)" --no-print-directory "$(ACTIVATE_SCRIPT)"; \ + 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..."; \ - $(MAKE) --no-print-directory -B $(VENV)/bin/activate; \ + echo "Python virtual environment found but dependencies are missing; bootstrapping..."; \ + "$(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: - @echo "🔧 Creating Python virtual environment at $(VENV)..." - @$(BOOTSTRAP_PYTHON) -m venv $(VENV) +$(VENV_PYTHON): + @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..." +$(ACTIVATE_SCRIPT): $(VENV_PYTHON) + @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..." +# Setup ontology-management-base submodule and LinkML fork +_setup_submodules: + @echo "Setting up ontology-management-base submodule..." @set -e; \ - if [ -f "$(OMB_SUBMODULE_DIR)/Makefile" ]; then \ - $(MAKE) --no-print-directory -C $(OMB_SUBMODULE_DIR) setup \ + 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"; \ + 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"; \ + PYTHON="$(PYTHON_ABS)"; \ + 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..." - @cd $(TS_DIR) && corepack enable && yarn install - @echo "✅ TypeScript bootstrap complete" +_setup_ts: + @echo "Bootstrapping TypeScript dependencies..." + @cd "$(TS_DIR)" && $(YARN) install + @echo "OK: TypeScript bootstrap complete" # Install package (user mode) install: - @echo "🔧 Installing package in editable mode..." - @$(MAKE) --no-print-directory $(VENV)/bin/python3 + @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_PYTHON)" +endif @$(PIP) install -e . - @echo "✅ Package installation complete" + @echo "OK: Package installation complete" -# Install with dev dependencies -install-dev: - @echo "🔧 Installing development dependencies..." - @$(MAKE) --no-print-directory $(VENV)/bin/python3 +# Install with dev dependencies (works in CI without venv creation) +_install_dev: + @echo "Installing development dependencies..." +ifndef CI + @"$(MAKE)" --no-print-directory "$(VENV_PYTHON)" +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..." - @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 + @echo "Generating artifacts from LinkML schemas..." + @PYTHONPATH="src/python$(PYTHONPATH_SEP)$$PYTHONPATH" "$(PYTHON)" src/python/harbour/generate_artifacts.py @echo "" - @echo "✅ Artifacts generated in artifacts/" + @echo "OK: Artifacts generated in artifacts/" # 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 "✅ Validation complete" + @echo "Validating harbour credentials..." + @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: +_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 - @echo "✅ SHACL validation complete" + @echo "Running SHACL data conformance check on examples..." + @cd "$(OMB_SUBMODULE_DIR)" && \ + tmp_output=$$(mktemp) && \ + 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 ; \ + 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" # 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 "✅ Pre-commit checks complete" + @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 + @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 black src/python/ tests/ - @$(PYTHON) -m isort src/python/ tests/ - @echo "✅ Python formatting complete" + @echo "Formatting Python code..." + @"$(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 +_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 "✅ Python tests complete" + @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 -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 "✅ Coverage run complete" + @echo "Running Python tests with coverage..." + @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 -build-ts: - @echo "🔧 Building TypeScript..." - @cd $(TS_DIR) && corepack enable && yarn install && yarn build - @echo "✅ TypeScript build complete" +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)" && $(YARN) install && $(YARN) build + @echo "OK: TypeScript build complete" + +_test_ts: + @echo "Running TypeScript tests..." + @cd "$(TS_DIR)" && $(YARN) install && $(YARN) test + @echo "OK: TypeScript tests complete" + +_lint_ts: + @echo "Linting TypeScript..." + @cd "$(TS_DIR)" && $(YARN) install && $(YARN) lint + @echo "OK: TypeScript lint complete" -test-ts: - @echo "🔧 Running TypeScript tests..." - @cd $(TS_DIR) && corepack enable && yarn test - @echo "✅ TypeScript tests 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_SEP)$$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 -lint-ts: - @echo "🔧 Linting TypeScript..." - @cd $(TS_DIR) && corepack enable && yarn lint - @echo "✅ TypeScript lint complete" +_story_sign: + $(call check_dev_setup) + @echo "Signing Harbour example storylines..." + @rm -rf examples/signed examples/gaiax/signed + @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..." + @PYTHONIOENCODING=utf-8 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 + @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 +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)..." - @$(MAKE) --no-print-directory lint - @$(MAKE) --no-print-directory test - @echo "✅ Default quality pipeline complete" + @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: Full quality pipeline complete" # Run all tests (Python + TypeScript) -test-all: - @echo "🔧 Running all tests (Python + TypeScript)..." - @$(MAKE) --no-print-directory test - @$(MAKE) --no-print-directory test-ts - @echo "✅ All tests complete" +_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 + @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" diff --git a/README.md b/README.md index fcf3926..2152f44 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: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 @@ -28,13 +28,17 @@ 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`). -> Use `make install-dev` only if you need to refresh an existing Python environment. +> `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 @@ -64,7 +68,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 +93,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); @@ -100,13 +104,12 @@ 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-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 | | ----------------------------------- | ------------------------- | --------------------- | --------------------- | | `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,20 +117,20 @@ 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 { "@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:web:trust-anchor.example.com", + "issuer": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3", "validFrom": "2024-01-15T00:00:00Z", "credentialSubject": { - "id": "did:web:participant.example.com", + "id": "did:ethr:0x14a34:0xf7ef72f0ad8256df1a731ca0cb26230683518dab", "type": "harbour:LegalPerson", "name": "Example Corporation GmbH", "gxParticipant": { @@ -146,7 +149,7 @@ The composition pattern keeps harbour properties on the harbour-typed outer node }, "credentialStatus": [ { - "id": "did:web:issuer.example.com:revocation#abc123", + "id": "did:ethr:0x14a34:0xf8abbe34d226eff3c1bc85ba9d567b9ab50b38c3:services:revocation-registry#abc123", "type": "harbour:CRSetEntry", "statusPurpose": "revocation" } @@ -165,17 +168,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 -``` - -### Run Tests -```bash -# Run all fixture validations via pytest -make test +# See validation subcommands +make validate help ``` ## CLI Usage @@ -204,7 +203,7 @@ python -m harbour.x509 generate --key key.jwk --subject "Test Issuer" --output c ## Package Structure -``` +```text src/ ├── python/ │ ├── harbour/ # Crypto library @@ -215,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) @@ -230,9 +228,10 @@ 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-ethr/ # Example did:ethr DID documents used by examples tests/ ├── fixtures/ # Shared test fixtures @@ -246,14 +245,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/ +├── 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-core-credential/ # Base OWL/SHACL/context +└── harbour-gx-credential/ # Domain OWL/SHACL/context ``` ## Testing @@ -262,17 +259,25 @@ artifacts/ # Generated per domain (make generate) # Python tests make test -# TypeScript tests -cd src/typescript/harbour && yarn test +# TypeScript tests (requires make build first) +make build +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 first) +make test interop -# All tests with coverage -make test-cov +# Full pipeline: Python + SHACL conformance + TypeScript (builds TS automatically) +make test full + +# Python tests with coverage +make test cov # Lint make lint + +# See grouped subcommands +make test help +make story help ``` ## Documentation diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 2b5208d..0000000 --- a/docs/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# Harbour Credentials — Design Documentation - -## Package Structure (Current) - -``` -harbour-credentials/ -├── src/ -│ ├── python/ -│ │ ├── harbour/ # Crypto library (6 modules) -│ │ └── credentials/ # LinkML pipeline (3 modules) -│ └── typescript/ -│ └── harbour/ # Crypto library (6 modules) -├── tests/ -│ ├── fixtures/ # Shared fixtures (credentials, keys, tokens) -│ ├── interop/ # Cross-runtime interoperability tests -│ ├── python/ # Python tests (harbour + credentials) -│ └── typescript/harbour/ # TypeScript tests -├── linkml/ # LinkML schemas (harbour.yaml, core.yaml, gaiax-domain.yaml) -└── artifacts/ # Generated OWL/SHACL/context (per domain) -``` - -## Architecture Decision Records - -| # | Decision | Status | -|---|----------|--------| -| [001](decisions/001-vc-securing-mechanism.md) | SD-JWT-VC (EUDI) + VC-JOSE-COSE (Gaia-X) — dual format | Accepted | -| [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:web + 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 | - -## Format Relationship - -``` -LinkML Schema → JSON-LD Context + SHACL (schema validation) - │ - ┌────────────┼────────────┐ - ▼ ▼ ▼ - JSON-LD VCs VC-JOSE-COSE SD-JWT-VC - (examples) (Gaia-X JWT) (EUDI wallet) -``` - -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. diff --git a/docs/api/python/index.md b/docs/api/python/index.md index 969be87..10c6cd2 100644 --- a/docs/api/python/index.md +++ b/docs/api/python/index.md @@ -6,12 +6,14 @@ 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.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 6e4570a..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,10 +73,15 @@ interface KbJwtOptions { audience: string; issuedAt?: number; } + +interface SdJwtVpOptions { + disclosedClaims?: string[]; + evidence?: Record; + nonce?: string; + audience?: string; +} ``` ## 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/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..36052f8 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,126 @@ +# Architecture Overview + +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
(ComplianceCredential)"] + 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/ +├── src/ +│ ├── python/ +│ │ ├── harbour/ # Crypto library (6 modules) +│ │ └── credentials/ # LinkML pipeline (3 modules) +│ └── typescript/ +│ └── harbour/ # Crypto library (6 modules) +├── tests/ +│ ├── fixtures/ # Shared fixtures (credentials, keys, tokens) +│ ├── interop/ # Cross-runtime interoperability tests +│ ├── python/ # Python tests (harbour + credentials) +│ └── typescript/harbour/ # TypeScript tests +├── 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 | +|---|----------|--------| +| [001](decisions/001-vc-securing-mechanism.md) | SD-JWT-VC (EUDI) + VC-JOSE-COSE (Gaia-X) — dual format | Accepted | +| [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 | +| [005](decisions/005-did-ethr-migration.md) | did:ethr migration to Base L2 | Accepted | + +## Format Relationship + +```text +LinkML Schema → JSON-LD Context + SHACL (schema validation) + │ + ┌────────────┼────────────┐ + ▼ ▼ ▼ + JSON-LD VCs VC-JOSE-COSE SD-JWT-VC + (examples) (Gaia-X JWT) (EUDI wallet) +``` + +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 serialisations. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..e23ea8e --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,158 @@ +# 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 + + # PowerShell + .\.venv\Scripts\Activate.ps1 + + # macOS / Linux / Git Bash + source .venv/bin/activate + ``` + +3. **Verify everything works**: + + ```bash + make test full + 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 full + +# 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/): + +```text +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 full`) +- [ ] 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/decisions/001-vc-securing-mechanism.md b/docs/decisions/001-vc-securing-mechanism.md index ffa7371..cd6cfd5 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`) @@ -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 @@ -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: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 @@ -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:web` (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:ethr` (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+ld+jwt"}` | -| Payload | Full W3C VCDM 2.0 JSON-LD | -| Key resolution | `did:web` (Gaia-X) + `x5c` (EUDI alignment) | -| JS library | npm `jose` | -| Python library | `joserfc` | +| 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:ethr` (Gaia-X) + `x5c` (EUDI alignment) | +| 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:web` | -| 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:ethr` | +| 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 -``` - ┌─────────────────────────────┐ - │ LinkML Schema Definition │ - │ (harbour.yaml, etc.) │ - └──────────┬──────────────────┘ +```text + ┌────────────────────────────────────────┐ + │ 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,19 +199,22 @@ 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:web) +- 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) ### 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..bc9a14d 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/ @@ -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/ ``` @@ -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 ✓ @@ -77,10 +78,11 @@ 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: + 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 1414a68..21a36b9 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 @@ -38,11 +41,12 @@ 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) **ES256 key:** + ```json { "kty": "EC", @@ -52,7 +56,8 @@ The original choice of Ed25519 must be revised based on regulatory requirements: } ``` -**Ed25519 key (legacy):** +**Ed25519 key:** + ```json { "kty": "OKP", @@ -61,29 +66,32 @@ 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:ethr** | Gaia-X | `kid` | `did:ethr:0x14a34:
#controller` | | **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:web (Gaia-X):** -- Resolves to DID Document at well-known URL -- DID Document contains JWK public key(s) -- Used for organizational identities (ASCS, BMW, etc.) +**did:ethr (Gaia-X):** + +- 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 **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 new file mode 100644 index 0000000..4246093 --- /dev/null +++ b/docs/decisions/005-did-ethr-migration.md @@ -0,0 +1,81 @@ +# ADR-005: Migration from did:web / did:webs to did:ethr + +## Status + +**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** | Resolved signer DIDs expose a local P-256 `#controller` key | +| **DID format** | `did:ethr::` | + +### DID document structure + +The EthereumDIDRegistry and project-specific resolver derive DID documents from +on-chain state: + +- 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 + +### 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](../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/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 +``` diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 825ddcf..8702779 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -11,11 +11,16 @@ 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 -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 @@ -26,7 +31,7 @@ pip install -e ".[dev]" ```bash make setup -make install-dev +make install dev ``` ## TypeScript @@ -40,27 +45,27 @@ 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 -npm install -npm run build +corepack yarn install +corepack yarn 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 f51a274..0931919 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -4,124 +4,126 @@ 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, + vct="https://example.com/credential/v1", + disclosable=["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 { 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 -- [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/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/docs/guide/delegated-signing.md b/docs/guide/delegated-signing.md new file mode 100644 index 0000000..a96f4b8 --- /dev/null +++ b/docs/guide/delegated-signing.md @@ -0,0 +1,382 @@ +# 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 + +```text +User Signing Service Blockchain + | | | + | 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 + +### 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:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "credentialSubject": { + "id": "did:ethr:0x14a34:0x26e4...16c9", + "type": "harbour:NaturalPerson", + "name": "Alice Smith", // ← Disclosable (PII) + "email": "alice.smith@example.com", // ← Disclosable (PII) + "memberOf": "did:ethr:0x14a34:0xf7ef...dab" + } +} +``` + +### 2. DID Document + +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", + { + "JsonWebKey": "https://w3id.org/security#JsonWebKey", + "publicKeyJwk": { + "@id": "https://w3id.org/security#publicKeyJwk", + "@type": "@json" + } + } + ], + "id": "did:ethr:0x14a34:0x26e4...16c9", + "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": [ + "did:ethr:0x14a34:0x26e4...16c9#controller" + ], + "assertionMethod": [ + "did:ethr:0x14a34:0x26e4...16c9#controller" + ] +} +``` + +See [`examples/did-ethr/`](../../examples/did-ethr/) for complete DID documents. + +### Repository Boundary (did:ethr) + +This repository verifies signatures and hash bindings, but it does **not** host or publish DID documents. + +- 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). + +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 custom P-256 controller 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)): + +```json +{ + "type": "harbour.delegate:data.purchase", + "credential_ids": ["harbour_natural_person"], + "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:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c" + } +} +``` + +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: + +1. **Selective disclosure**: Only non-PII claims disclosed +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 + +```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 evidence (OID4VP-aligned) +evidence = [{ + "type": "DelegatedSignatureEvidence", + "transaction_data": { + "type": "harbour.delegate:data.purchase", + "credential_ids": ["harbour_natural_person"], + "nonce": "da9b1009", + "iat": 1771934400, + "txn": { + "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "price": "100", + "currency": "ENVITED" + } + }, + "delegatedTo": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202" +}] + +# 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="da9b1009", + audience="did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202" +) +``` + +### TypeScript Example + +```typescript +import { issueSdJwtVp } from '@reachhaven/harbour-credentials'; + +const sdJwtVp = await issueSdJwtVp(sdJwtVc, holderPrivateKey, { + disclosures: ['memberOf'], + evidence: [{ + type: 'DelegatedSignatureEvidence', + transaction_data: { + type: 'harbour.delegate:data.purchase', + credential_ids: ['harbour_natural_person'], + nonce: 'da9b1009', + iat: 1771934400, + txn: { + asset_id: 'urn:uuid:550e8400-e29b-41d4-a716-446655440000', + price: '100', + currency: 'ENVITED' + } + }, + delegatedTo: 'did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202' + }], + nonce: 'da9b1009', + audience: 'did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202' +}); +``` + +`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: + +```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="da9b1009", + expected_audience="did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202" +) + +# Check transaction data matches original request +tx = result["evidence"][0]["transaction_data"] +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) +# ... revocation check ... + +# All checks pass -> execute transaction +``` + +## Receipt Credential + +After executing the transaction, the signing service issues a **receipt credential** (SD-JWT-VC) with `DelegatedSignatureEvidence`: + +```json +{ + "type": ["VerifiableCredential", "harbour:DelegatedSigningReceipt"], + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "evidence": [{ + "type": "harbour:DelegatedSignatureEvidence", + "verifiablePresentation": "", + "delegatedTo": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "transaction_data": { "..." } + }], + "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 + +The SD-JWT VP enables **three-layer privacy-preserving audit**: + +| 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 transaction data prevents replay attacks: + +- Signing service generates unique nonce per request +- VP must contain matching nonce in KB-JWT +- 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:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202" +) +``` + +### 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 (integrator-provided resolver) +did_doc = resolve_did("did:ethr:0x14a34:0x26e4...16c9") + +# 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 creates OID4VP transaction data: "Purchase 'Weather Data 2024' for 100 ENVITED" +3. User creates consent VP with wallet +4. Harbour executes blockchain transaction +5. Receipt credential issued with `DelegatedSignatureEvidence` + +### Contract Signing + +User signs legal contract: + +1. Contract platform prepares document +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 + +### Access Delegation + +User grants access to resource: + +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 + +## 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 new file mode 100644 index 0000000..30a2198 --- /dev/null +++ b/docs/guide/evidence.md @@ -0,0 +1,210 @@ +# 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 + +### CredentialEvidence + +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. + +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 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, 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", "harbour:VerifiablePresentation"], + "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:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", + "type": "harbour:LinkedCredentialService", + "didcore:serviceEndpoint": {"id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774"} + } + } + ] + } +} +``` + +**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 + +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 signing service issues a receipt credential after executing a blockchain purchase on behalf of a user. + +```json +{ + "type": "harbour:DelegatedSignatureEvidence", + "verifiablePresentation": "", + "delegatedTo": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "transaction_data": { + "type": "harbour.delegate:data.purchase", + "credential_ids": ["harbour_natural_person"], + "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:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c" + } + }, + "challenge": "da9b1009 HARBOUR_DELEGATE c3d4ba771c1103935ab4121874c4b3a78c8471719c80f60d59ca5811e232089b" +} +``` + +**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 | +|--------------|----------|------------------| +| `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 + +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. **Three-layer disclosure**: + - Public: CRSet + transaction hash + signature validity + - Authorized: Transaction details (asset, price) + - Full audit: Identity details (name, email, organization) + +## 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:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "credentialSubject": {...}, + "evidence": [ + { + "type": "harbour:CredentialEvidence", + "verifiablePresentation": authorization_vp_jwt + } + ] +} + +signed_vc = sign_vc_jose(credential, issuer_private_key) +``` + +## Schema Definition + +Evidence types are defined in `linkml/harbour-core-credential.yaml`: + +```yaml +Evidence: + abstract: true + class_uri: cred:Evidence + slots: + - type + +CredentialEvidence: + is_a: Evidence + class_uri: harbour:CredentialEvidence + slots: + - verifiablePresentation + slot_usage: + verifiablePresentation: + required: true + +DelegatedSignatureEvidence: + is_a: Evidence + class_uri: harbour:DelegatedSignatureEvidence + slots: + - verifiablePresentation + - delegatedTo + - transaction_data + slot_usage: + verifiablePresentation: + required: true + delegatedTo: + required: true + transaction_data: + required: true +``` + +## 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..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 @@ -13,60 +18,62 @@ ## 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 +- [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 -- [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 +- [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/schema/credential-model.md b/docs/schema/credential-model.md new file mode 100644 index 0000000..651a1ec --- /dev/null +++ b/docs/schema/credential-model.md @@ -0,0 +1,397 @@ +# 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[] + credentialStatus : CRSetEntry[] ⟨required⟩ + } + + class ComplianceCredential { + <> + evidence : Evidence[] ⟨required⟩ + } + + class LegalPersonCredential { + class_uri = harbour.gx:LegalPersonCredential + vct = "…/LegalPersonCredential" + validFrom : required + evidence : required + } + + class NaturalPersonCredential { + class_uri = harbour.gx:NaturalPersonCredential + vct = "…/NaturalPersonCredential" + validFrom : required + evidence : required + } + + W3C_VC_Envelope <|-- HarbourCredential : imports + strengthens + HarbourCredential <|-- ComplianceCredential + ComplianceCredential <|-- 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 | 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`. + 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⟩ + } + + class CredentialEvidence { + verifiablePresentation : VP ⟨required⟩ + } + + class DelegatedSignatureEvidence { + verifiablePresentation : VP ⟨required⟩ + delegatedTo : uri ⟨required⟩ + transaction_data : object ⟨required⟩ + challenge : string + } + + Evidence <|-- CredentialEvidence + Evidence <|-- DelegatedSignatureEvidence +``` + +**`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 +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. + +### 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 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 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 | +|----------------|-------------|----------| +| `harbour.gx:LegalPersonCredential` | `harbour.gx:LegalPerson` | Organisation compliance attestation | +| `harbour.gx:NaturalPersonCredential` | `harbour.gx:NaturalPerson` | Individual identity | + +--- + +## 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 + +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: + +- Three `CompliantCredentialReference` slots with `digestSRI` integrity hashes +- Compliance metadata (`labelLevel`, `engineVersion`, `rulesVersion`, `validatedCriteria`) + +```mermaid +graph TD + 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 "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 + + A -->|verified| D + B -->|verified| D + C -->|verified| D + D --> E + E --> F & G & H + + style A fill:#e8f5e9,stroke:#2e7d32 + style B fill:#e8f5e9,stroke:#2e7d32 + style C 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 +``` + +### Two Delivery Patterns + +**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). + +**Embedded pattern** — input VCs are JSON-stringified inside +`embeddedCredential` for self-contained verification. The `digestSRI` +still serves as integrity proof. + +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. + +--- + +## 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 | +| `ComplianceCredential` | gx | ✅ | `HarbourCredential` | Gaia-X | +| `LegalPersonCredential` | gx | — | `ComplianceCredential` | Gaia-X | +| `NaturalPersonCredential` | gx | — | `HarbourCredential` | 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 new file mode 100644 index 0000000..1f1d930 --- /dev/null +++ b/docs/specs/delegation-challenge-encoding.md @@ -0,0 +1,672 @@ +# 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 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 | + +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: + +```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 c3d4ba771c1103935ab4121874c4b3a78c8471719c80f60d59ca5811e232089b +``` + +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) + +```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` | `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 + +```json +{ + "type": "harbour.delegate:data.purchase", + "credential_ids": ["harbour_natural_person"], + "transaction_data_hashes_alg": ["sha-256"], + "nonce": "da9b1009", + "iat": 1771934400, + "exp": 1771935300, + "description": "Purchase sensor data package", + "txn": { + "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "price": "100", + "currency": "ENVITED", + "marketplace": "did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c" + } +} +``` + +### 3.6 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: + +```text +da9b1009 HARBOUR_DELEGATE c3d4ba771c1103935ab4121874c4b3a78c8471719c80f60d59ca5811e232089b +``` + +--- + +## 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. + +### 4.1 Evidence with Embedded VP + +```json +{ + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["VerifiableCredential"], + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "validFrom": "2026-02-24T12:00:00Z", + "credentialSubject": { + "id": "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129" + }, + "evidence": [{ + "type": ["CredentialEvidence"], + "verifiablePresentation": { + "@context": ["https://www.w3.org/ns/credentials/v2"], + "type": ["VerifiablePresentation"], + "holder": "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129", + "verifiableCredential": [ + "" + ], + "proof": { + "type": "DataIntegrityProof", + "cryptosuite": "ecdsa-rdfc-2019", + "proofPurpose": "authentication", + "challenge": "da9b1009 HARBOUR_DELEGATE c3d4ba771c1103935ab4121874c4b3a78c8471719c80f60d59ca5811e232089b", + "domain": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "verificationMethod": "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129#controller", + "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[].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) + +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 + +```text +┌─────────┐ ┌─────────┐ ┌─────────┐ +│ 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.delegate:data.purchase", + "credential_ids": ["harbour_natural_person"], + "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:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c" + } +} +``` + +### 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:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "iat": 1709838604, + "sd_hash": "Dy-RYwZfaaoC3inJbLslgPvMp09bH-clYP_3qbRqtW4", + "transaction_data_hashes": ["7W0LFUTpMvb6nJK7ngamNNY0zNvxqJ-2jNXTmLzhWQE"], + "transaction_data_hashes_alg": "sha-256" +} +``` + +### 5.4 Dual Support + +Our challenge profile and OID4VP binding support both: + +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) + +These are two related but distinct representations and MUST be verified according to their respective rules. + +--- + +## 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 + +The implementation is in `src/python/harbour/delegation.py`: + +```python +from harbour.delegation import TransactionData, create_delegation_challenge, verify_challenge + +# Create OID4VP-aligned transaction data +tx = TransactionData.create( + action="data.purchase", + txn={ + "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "price": "100", + "currency": "ENVITED", + "marketplace": "did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c", + }, + credential_ids=["harbour_natural_person"], +) + +# Create challenge: " HARBOUR_DELEGATE " +challenge = create_delegation_challenge(tx) +print(f"Challenge: {challenge}") +print(f"Valid: {verify_challenge(challenge, tx)}") +``` + +### 8.2 TypeScript + +The implementation is in `src/typescript/harbour/delegation.ts`: + +```typescript +import { + createTransactionData, + createDelegationChallenge, + verifyChallenge, +} from '@reachhaven/harbour-credentials'; + +// Create OID4VP-aligned transaction data +const tx = createTransactionData({ + action: 'data.purchase', + txn: { + asset_id: 'urn:uuid:550e8400-e29b-41d4-a716-446655440000', + price: '100', + currency: 'ENVITED', + marketplace: 'did:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c', + }, + credentialIds: ['harbour_natural_person'], +}); + +// Create challenge: " HARBOUR_DELEGATE " +const challenge = await createDelegationChallenge(tx); +console.log('Challenge:', challenge); +console.log('Valid:', await verifyChallenge(challenge, 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 + +```text +╔═══════════════════════════════════════════════════════════════════════╗ +║ Harbour Signing Service requests your authorization ║ +╠═══════════════════════════════════════════════════════════════════════╣ +║ ║ +║ Action: Purchase data asset ║ +║ Asset: urn:uuid:550e8400-e29b-41d4-a716-44665544... ║ +║ Amount: 100 ENVITED ║ +║ ║ +╠═══════════════════════════════════════════════════════════════════════╣ +║ Service: did:ethr:0x14a34:0x9c2f...c697 ║ +║ 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 +from harbour.delegation import TransactionData, render_transaction_display + +tx = TransactionData.create( + action="data.purchase", + txn={"asset_id": "urn:uuid:550e8400...", "price": "100", "currency": "ENVITED"}, +) +print(render_transaction_display(tx)) +``` + +### 9.5 TypeScript Display Renderer + +```typescript +import { createTransactionData, renderTransactionDisplay } from '@reachhaven/harbour-credentials'; + +const tx = createTransactionData({ + action: 'data.purchase', + txn: { asset_id: 'urn:uuid:550e8400...', price: '100', currency: 'ENVITED' }, +}); +console.log(renderTransactionDisplay(tx)); +``` + +--- + +## 10. Examples + +### 10.1 Data Purchase Transaction + +These examples use the shared test vectors from `tests/fixtures/canonicalization-vectors.json`. + +**Transaction Data:** + +```json +{ + "type": "harbour.delegate:data.purchase", + "credential_ids": ["harbour_natural_person"], + "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:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c" + } +} +``` + +**Challenge:** + +```text +da9b1009 HARBOUR_DELEGATE c3d4ba771c1103935ab4121874c4b3a78c8471719c80f60d59ca5811e232089b +``` + +### 10.2 Blockchain Transfer Transaction + +**Transaction Data:** + +```json +{ + "type": "harbour.delegate:blockchain.transfer", + "credential_ids": ["default"], + "transaction_data_hashes_alg": ["sha-256"], + "nonce": "ef567890", + "iat": 1771934400, + "txn": { + "chain": "eip155:42793", + "amount": "1000000000000000000", + "recipient": "0xabcdef1234567890", + "contract": "0x1234567890abcdef" + } +} +``` + +**Challenge:** + +```text +ef567890 HARBOUR_DELEGATE 66d8768b6f6ae9d952f61c85414d22d504341da5d0ff0f65a45398246f1f630a +``` + +### 10.3 Contract Signature Transaction + +**Transaction Data:** + +```json +{ + "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": { + "document_hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "parties": ["did:ethr:0x14a34:0xAA11...2233", "did:ethr:0x14a34:0xBB44...5566"] + } +} +``` + +**Challenge:** + +```text +ab12cd34 HARBOUR_DELEGATE 573cc3da4d63242b2d8b950b29507b9b1e414d9330d3bb245ce7fb264b259601 +``` + +--- + +## 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.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"` | + +### Integration Example + +OID4VP authorization request: + +```json +{ + "response_type": "vp_token", + "client_id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "nonce": "da9b1009", + "transaction_data": [{ + "type": "harbour.delegate:data.purchase", + "credential_ids": ["harbour_natural_person"], + "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:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c" + } + }] +} +``` + +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` | `description` field (human-readable) | +| `uri` | Transaction reference (in `txn` object) | +| `nonce` | `nonce` field | +| `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**: + +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..8b6f7fb --- /dev/null +++ b/docs/specs/did-method-evaluation.md @@ -0,0 +1,164 @@ +# DID Method Evaluation: did:ethr + +> **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 rationale and the current Harbour resolver profile. + +## Glossary + +| Term | Definition | +|------|-----------| +| **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 pattern. | +| **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 | 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 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-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 + +```text +did:ethr::
+ +# Base Sepolia Testnet (development) +did:ethr:0x14a34:0x71C7656EC7ab88b098defB751B7401B5f6d8976F + +# Base Mainnet (production) +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 + +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", + { + "JsonWebKey": "https://w3id.org/security#JsonWebKey", + "publicKeyJwk": { + "@id": "https://w3id.org/security#publicKeyJwk", + "@type": "@json" + } + } + ], + "id": "did:ethr:0x14a34:0x71C7656EC7ab88b098defB751B7401B5f6d8976F", + "verificationMethod": [ + { + "id": "...#controller", + "type": "JsonWebKey", + "controller": "...", + "publicKeyJwk": { "kty": "EC", "crv": "P-256", "x": "...", "y": "..." } + } + ], + "authentication": ["...#controller"], + "assertionMethod": ["...#controller"] +} +``` + +## Key Management + +| Context | DID Method | kid Format | +|---------|-----------|------------| +| **EUDI** | X.509 | `x5c` header (no kid) | +| **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:` | `#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 + +| Network | Chain ID | Hex | Use | +|---------|----------|-----|-----| +| Base Sepolia | 84532 | 0x14a34 | Development, testing | +| Base Mainnet | 8453 | 0x2105 | Production | + +### RPC Endpoints + +- **Sepolia**: `https://sepolia.base.org` +- **Mainnet**: `https://mainnet.base.org` + +## Migration from did:web / did:webs + +The migration from did:web/did:webs to did:ethr involves: + +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](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) (baseline reference) +- [Base Documentation](https://docs.base.org/) +- `docs/did-identity-system.md` — Harbour-specific on-chain identity architecture overview + +### Archived Specifications + +These specifications are retained for historical reference but are no longer the active DID method: + +- `did-web-method.txt` — did:web specification (W3C CCG) *(superseded)* +- `did-webs-spec.md` — did:webs specification (ToIP) *(superseded)* +- `references/did-ethr-method-spec.md` — did:ethr method specification (active reference baseline) diff --git a/docs/specs/references/README.md b/docs/specs/references/README.md new file mode 100644 index 0000000..dbe7b8a --- /dev/null +++ b/docs/specs/references/README.md @@ -0,0 +1,83 @@ +# 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 | +|------|--------|--------------|---------| +| `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-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 | +| `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) | +| `keri-draft.md` | [KERI Draft](https://github.com/WebOfTrust/ietf-keri) | WebOfTrust / IETF | Apache 2.0 | + +## Download Date + +- `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 + +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:ethr (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: + +- **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 (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 +- **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 + +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/csc-data-model.md b/docs/specs/references/csc-data-model.md new file mode 100644 index 0000000..df46476 --- /dev/null +++ b/docs/specs/references/csc-data-model.md @@ -0,0 +1,53 @@ +# 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/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 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/docs/specs/references/did-webs-spec.md b/docs/specs/references/did-webs-spec.md new file mode 100644 index 0000000..e7c42fb --- /dev/null +++ b/docs/specs/references/did-webs-spec.md @@ -0,0 +1,1871 @@ +# 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. +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` +* `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. +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). +::: + +### 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. + 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. +::: + +#### 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. + 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. +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/gx-architecture-document-25.11.md b/docs/specs/references/gx-architecture-document-25.11.md new file mode 100644 index 0000000..d5f0520 --- /dev/null +++ b/docs/specs/references/gx-architecture-document-25.11.md @@ -0,0 +1,114 @@ +# 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 | + +### 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 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. + +### Compliance Pattern + +``` +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 — entity data stays on the +gx nodes, compliance metadata stays on the harbour 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 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/docs/specs/references/keri-draft.md b/docs/specs/references/keri-draft.md new file mode 100644 index 0000000..6c8bf9d --- /dev/null +++ b/docs/specs/references/keri-draft.md @@ -0,0 +1,1544 @@ +--- + +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.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/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/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 new file mode 100644 index 0000000..50bf741 --- /dev/null +++ b/docs/specs/references/sd-jwt-vc.md @@ -0,0 +1,66 @@ +# IETF SD-JWT-VC — SD-JWT-based Verifiable Digital Credentials + +**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 (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) | +| `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/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/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 new file mode 100644 index 0000000..0cd90d8 --- /dev/null +++ b/docs/specs/references/vc-jose-cose.md @@ -0,0 +1,64 @@ +# 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..d18b8ae --- /dev/null +++ b/examples/README.md @@ -0,0 +1,436 @@ +# 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: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:ethr] + 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 +``` + +## Core Credentials vs Gaia-X Domain + +The examples use a **two-tier layout**: + +- **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 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. + +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 + +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 [`gaiax/trust-anchor-credential.json`](gaiax/trust-anchor-credential.json). + +## Actors and Identities + +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 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: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` | — | + +> **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 P-256 verification methods: + +| Key | Relationship | Purpose | +|-----|-------------|---------| +| `#controller` | `authentication`, `assertionMethod` | Primary controller and credential issuance key | +| `#delegate-1` | `authentication`, `capabilityDelegation` | Delegated transaction signing | + +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. + +--- + +## 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: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:ethr 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 | +|------|-------------| +| [`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 + +```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: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: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:ethr` 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 | +|------|-------------| +| [`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 + +```python +# 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 +``` + +--- + +## 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:ethr) + 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 +([`gaiax/delegated-signing-receipt.json`](gaiax/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:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c", + }, + 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:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + }], + nonce=tx.nonce, + audience="did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", +) +``` + +```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:ethr) + 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 | +|------|-------------| +| [`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 + +```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:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", +) + +# 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 + +### Core skeletons (unsigned, expanded JSON-LD) + +| 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 storyline (`gaiax/`) + +| 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/`) + +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-ethr/`) + +| File | Actor | Method | +|------|-------|--------| +| [`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 + +```bash +source .venv/bin/activate +PYTHONPATH=src/python:$PYTHONPATH python -m credentials.example_signer examples/ +``` + +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 +> 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-ethr/README.md) — All example `did:ethr` identifiers diff --git a/examples/credential-with-evidence.json b/examples/credential-with-evidence.json new file mode 100644 index 0000000..a1e6a4e --- /dev/null +++ b/examples/credential-with-evidence.json @@ -0,0 +1,69 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/core/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:VerifiableCredential" + ], + "id": "urn:uuid:11111111-1111-1111-1111-111111111111", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "validFrom": "2024-01-15T00:00:00Z", + "validUntil": "2025-01-15T00:00:00Z", + "credentialSubject": { + "id": "did:ethr:0x14a34:0xa1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + }, + "credentialStatus": [ + { + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/a1b2c3d4e5f67890", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ], + "evidence": [ + { + "type": [ + "harbour:CredentialEvidence" + ], + "verifiablePresentation": { + "@context": [ + "https://www.w3.org/ns/credentials/v2" + ], + "type": [ + "VerifiablePresentation", + "harbour:VerifiablePresentation" + ], + "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/core/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:VerifiableCredential" + ], + "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "id": "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/lcs00000001", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ] + } + ] + } + } + ] +} diff --git a/examples/credential-with-nested-evidence.json b/examples/credential-with-nested-evidence.json new file mode 100644 index 0000000..255088c --- /dev/null +++ b/examples/credential-with-nested-evidence.json @@ -0,0 +1,113 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/core/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:VerifiableCredential" + ], + "id": "urn:uuid:22222222-2222-2222-2222-222222222222", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "validFrom": "2024-01-15T00:00:00Z", + "validUntil": "2025-01-15T00:00:00Z", + "credentialSubject": { + "id": "did:ethr:0x14a34:0xb2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2b2" + }, + "credentialStatus": [ + { + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/b2c3d4e5f6a78901", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ], + "evidence": [ + { + "type": [ + "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:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/core/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:VerifiableCredential" + ], + "id": "urn:uuid:33333333-3333-3333-3333-333333333333", + "issuer": "did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3", + "validFrom": "2024-01-10T00:00:00Z", + "credentialSubject": { + "id": "did:ethr:0x14a34:0xd4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4d4" + }, + "credentialStatus": [ + { + "id": "did:ethr:0x14a34:0xc3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3c3:services:revocation-registry/c3d4e5f6a7b89012", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ], + "evidence": [ + { + "type": [ + "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:0xe5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/core/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:VerifiableCredential" + ], + "issuer": "did:ethr:0x14a34:0xe5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "id": "did:ethr:0x14a34:0xe5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5", + "type": "harbour:LinkedCredentialService", + "didcore:serviceEndpoint": { + "id": "https://resolver.harbour.id/credentials/did:ethr:0x14a34:0xe5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5" + } + }, + "credentialStatus": [ + { + "id": "did:ethr:0x14a34:0xe5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5:services:revocation-registry/lcs00000003", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ] + } + ] + } + } + ] + } + ] + } + } + ] +} diff --git a/examples/did-ethr/README.md b/examples/did-ethr/README.md new file mode 100644 index 0000000..b40c631 --- /dev/null +++ b/examples/did-ethr/README.md @@ -0,0 +1,53 @@ +# did:ethr DID Documents + +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 + +| 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 signer DID document follows the Harbour example profile: + +- **`#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 + +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 + +- 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 + +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 new file mode 100644 index 0000000..c4e656b --- /dev/null +++ b/examples/did-ethr/harbour-signing-service.did.json @@ -0,0 +1,66 @@ +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/reachhaven/harbour/core/v1/", + { + "JsonWebKey": "https://w3id.org/security#JsonWebKey", + "publicKeyJwk": { + "@id": "https://w3id.org/security#publicKeyJwk", + "@type": "@json" + } + } + ], + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "verificationMethod": [ + { + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#controller", + "type": "JsonWebKey", + "controller": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "5TDhagEwEJvWbr7gt91Pds6g74LVYlqunw6a863jAoQ", + "y": "uAaJmh4wdv9sAacVZyMDF55WscI8Gk9NwdVJzXjYek4" + } + }, + { + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#delegate-1", + "type": "JsonWebKey", + "controller": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "5TDhagEwEJvWbr7gt91Pds6g74LVYlqunw6a863jAoQ", + "y": "uAaJmh4wdv9sAacVZyMDF55WscI8Gk9NwdVJzXjYek4" + } + } + ], + "authentication": [ + "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#controller" + ], + "assertionMethod": [ + "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#controller" + ], + "capabilityDelegation": [ + "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#delegate-1" + ], + "service": [ + { + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202#service-1", + "type": "harbour:TrustAnchorService", + "serviceEndpoint": { + "@type": "schema: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", + "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 new file mode 100644 index 0000000..a7429cf --- /dev/null +++ b/examples/did-ethr/harbour-trust-anchor.did.json @@ -0,0 +1,40 @@ +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/reachhaven/harbour/core/v1/", + { + "JsonWebKey": "https://w3id.org/security#JsonWebKey", + "publicKeyJwk": { + "@id": "https://w3id.org/security#publicKeyJwk", + "@type": "@json" + } + } + ], + "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", + "verificationMethod": [ + { + "id": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774#controller", + "type": "JsonWebKey", + "controller": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "XHiins22glZnQ_fFRbt1biH1-0IUj2gpl0jbaUxe1Cc", + "y": "jW9z3Tz4x1ontNTdV3apbP3e8odM3ln9BHyu0zndRM8" + } + } + ], + "authentication": [ + "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774#controller" + ], + "assertionMethod": [ + "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774#controller" + ], + "service": [ + { + "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 new file mode 100644 index 0000000..60741e4 --- /dev/null +++ b/examples/did-ethr/legal-person-0aa6d7ea-27ef-416f-abf8-9cb634884e66.did.json @@ -0,0 +1,32 @@ +{ + "@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:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", + "verificationMethod": [ + { + "id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#controller", + "type": "JsonWebKey", + "controller": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "cA5C-HS35A0oj56Udl_HS7nvtAwpWTf3fAGXJFYm3Qo", + "y": "Y16LRNZm58cqhsPb0XsWqtixDYDcKUgdGsiiici7NNo" + } + } + ], + "authentication": [ + "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#controller" + ], + "assertionMethod": [ + "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 new file mode 100644 index 0000000..64d64b3 --- /dev/null +++ b/examples/did-ethr/natural-person-550e8400-e29b-41d4-a716-446655440000.did.json @@ -0,0 +1,32 @@ +{ + "@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:0x272c04206c826047add586cbf7f4ffc4386da129", + "verificationMethod": [ + { + "id": "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129#controller", + "type": "JsonWebKey", + "controller": "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "tKjHMweiTEmNdgXye76UgmVSMA7mg5lsdZeav2alTyY", + "y": "lI83pcZ5BeUmOdrmLgx0KJ0DTbpcTC320WoryselneU" + } + } + ], + "authentication": [ + "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129#controller" + ], + "assertionMethod": [ + "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129#controller" + ] +} diff --git a/examples/gaiax/README.md b/examples/gaiax/README.md new file mode 100644 index 0000000..df10f6b --- /dev/null +++ b/examples/gaiax/README.md @@ -0,0 +1,78 @@ +# Gaia-X Domain Credentials + +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/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) | +| `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 + +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/core/v1/", + "https://w3id.org/reachhaven/harbour/gx/v1/" +] +``` + +## 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. + +See the parent [`examples/README.md`](../README.md) for the full user journey +with sequence diagrams. diff --git a/examples/gaiax/delegated-signing-receipt.json b/examples/gaiax/delegated-signing-receipt.json new file mode 100644 index 0000000..78d49f8 --- /dev/null +++ b/examples/gaiax/delegated-signing-receipt.json @@ -0,0 +1,91 @@ +{ + "@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/delegate/v1/" + ], + "type": [ + "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": "harbour:TransactionReceipt", + "transactionHash": "c3d4ba771c1103935ab4121874c4b3a78c8471719c80f60d59ca5811e232089b", + "blockchainTxId": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + }, + "credentialStatus": [ + { + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/f7e8d9c0b1a23456", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ], + "evidence": [ + { + "type": [ + "harbour:SignatureEvidence" + ], + "verifiablePresentation": { + "@context": [ + "https://www.w3.org/ns/credentials/v2" + ], + "type": [ + "VerifiablePresentation", + "harbour:VerifiablePresentation" + ], + "holder": "did:jwk:eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6IjZka1U2Wk1GSzc5V3dpY3dKNXJieEUxM3pTdWtCWTJPb0VpVlVFanFNRWMiLCJ5IjoiUm5Iem55VmxyUFNNVDdpckRzMTVEOXd4Z01vamlTREFRcGZGaHFUa0xSWSJ9", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/core/v1/", + "https://w3id.org/reachhaven/harbour/gx/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:VerifiableCredential" + ], + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "validFrom": "2024-01-15T00:00:00Z", + "credentialSubject": { + "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": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ] + } + ] + }, + "delegatedTo": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "transaction_data": { + "type": "harbour.delegate:data.purchase", + "credential_ids": [ + "harbour_natural_person" + ], + "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:ethr:0x14a34:0x89fe5e7f506d992f76bcba309773c0ee3ee6039c" + } + }, + "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..79f50e2 --- /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": "https://schema.org/" + } + ], + "type": [ + "VerifiableCredential" + ], + "id": "urn:uuid:f1a2b3c4-d5e6-7890-abcd-ef1234567890", + "issuer": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", + "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..6a4932d --- /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:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "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..79cb60b --- /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:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", + "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..25e02f8 --- /dev/null +++ b/examples/gaiax/legal-person-credential-embedded.json @@ -0,0 +1,99 @@ +{ + "@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:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "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\":\"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": { + "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: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\"}}", + "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantTermsVC" + }, + "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:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/e1e2e3e4e5e6e7e8", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ], + "evidence": [ + { + "type": [ + "harbour:CredentialEvidence" + ], + "verifiablePresentation": { + "@context": [ + "https://www.w3.org/ns/credentials/v2" + ], + "type": [ + "VerifiablePresentation", + "harbour:VerifiablePresentation" + ], + "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/core/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:VerifiableCredential" + ], + "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "id": "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/lcs00000001", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ] + } + ] + } + } + ] +} diff --git a/examples/gaiax/legal-person-credential.json b/examples/gaiax/legal-person-credential.json new file mode 100644 index 0000000..ac28be2 --- /dev/null +++ b/examples/gaiax/legal-person-credential.json @@ -0,0 +1,96 @@ +{ + "@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:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "validFrom": "2024-01-16T00:00:00Z", + "validUntil": "2025-01-16T00:00:00Z", + "credentialSubject": { + "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", + "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantLegalPersonVC" + }, + "harbour.gx:compliantRegistrationVC": { + "type": "harbour.gx:CompliantCredentialReference", + "harbour.gx:credentialType": "gx:VatID", + "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", + "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantTermsVC" + }, + "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:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/a1b2c3d4e5f67890", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ], + "evidence": [ + { + "type": [ + "harbour:CredentialEvidence" + ], + "verifiablePresentation": { + "@context": [ + "https://www.w3.org/ns/credentials/v2" + ], + "type": [ + "VerifiablePresentation", + "harbour:VerifiablePresentation" + ], + "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/core/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:VerifiableCredential" + ], + "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "id": "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/lcs00000001", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ] + } + ] + } + } + ] +} diff --git a/examples/gaiax/natural-person-credential.json b/examples/gaiax/natural-person-credential.json new file mode 100644 index 0000000..8ac2886 --- /dev/null +++ b/examples/gaiax/natural-person-credential.json @@ -0,0 +1,154 @@ +{ + "@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:NaturalPersonCredential" + ], + "id": "urn:uuid:b2c3d4e5-f6a7-8901-bcde-f23456789012", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "validFrom": "2024-01-15T00:00:00Z", + "validUntil": "2025-01-15T00:00:00Z", + "credentialSubject": { + "id": "did:ethr:0x14a34:0x272c04206c826047add586cbf7f4ffc4386da129", + "type": "harbour.gx:NaturalPerson", + "givenName": "Alice", + "familyName": "Smith", + "email": "alice.smith@example.com", + "memberOf": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", + "address": { + "type": "gx:Address", + "countryCode": "DE", + "countryName": "Germany", + "locality": "Munich" + } + }, + "credentialStatus": [ + { + "id": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/b2c3d4e5f6a78901", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ], + "evidence": [ + { + "type": [ + "harbour:CredentialEvidence" + ], + "verifiablePresentation": { + "@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:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", + "verifiableCredential": [ + { + "@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-ef1234567899", + "issuer": "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "validFrom": "2024-01-16T00:00:00Z", + "validUntil": "2025-01-16T00:00:00Z", + "credentialSubject": { + "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", + "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantLegalPersonVC" + }, + "harbour.gx:compliantRegistrationVC": { + "type": "harbour.gx:CompliantCredentialReference", + "harbour.gx:credentialType": "gx:VatID", + "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", + "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantTermsVC" + }, + "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:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/a1b2c3d4e5f67899", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ], + "evidence": [ + { + "type": [ + "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:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/core/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:VerifiableCredential" + ], + "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "id": "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/lcs00000001", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ] + } + ] + } + } + ] + } + ] + } + } + ] +} diff --git a/examples/gaiax/participant-vp.json b/examples/gaiax/participant-vp.json new file mode 100644 index 0000000..fe826d5 --- /dev/null +++ b/examples/gaiax/participant-vp.json @@ -0,0 +1,194 @@ +{ + "@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": "https://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ß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" + } + } + }, + { + "@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:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "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:0x31f1ca3dc5da9f83f360d805662d11a418950202", + "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", + "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantLegalPersonVC" + }, + "harbour.gx:compliantRegistrationVC": { + "type": "harbour.gx:CompliantCredentialReference", + "harbour.gx:credentialType": "gx:VatID", + "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", + "@id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93#compliantTermsVC" + }, + "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:0x31f1ca3dc5da9f83f360d805662d11a418950202:services:revocation-registry/a1b2c3d4e5f67890", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ], + "evidence": [ + { + "type": [ + "harbour:CredentialEvidence" + ], + "verifiablePresentation": { + "@context": [ + "https://www.w3.org/ns/credentials/v2" + ], + "type": [ + "VerifiablePresentation", + "harbour:VerifiablePresentation" + ], + "holder": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/core/v1/" + ], + "type": [ + "VerifiableCredential", + "harbour:VerifiableCredential" + ], + "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", + "validFrom": "2024-01-01T00:00:00Z", + "credentialSubject": { + "id": "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/lcs00000001", + "type": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ] + } + ] + } + } + ] + } + ] +} diff --git a/examples/gaiax/trust-anchor-credential.json b/examples/gaiax/trust-anchor-credential.json new file mode 100644 index 0000000..e8bc185 --- /dev/null +++ b/examples/gaiax/trust-anchor-credential.json @@ -0,0 +1,27 @@ +{ + "@context": [ + "https://www.w3.org/ns/credentials/v2", + "https://w3id.org/reachhaven/harbour/core/v1/" + ], + "type": [ + "VerifiableCredential", + "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": "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": "harbour:CRSetEntry", + "statusPurpose": "revocation" + } + ] +} 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..c36e20e --- /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": "https://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/examples/legal-person-credential.json b/examples/legal-person-credential.json deleted file mode 100644 index f4063dc..0000000 --- a/examples/legal-person-credential.json +++ /dev/null @@ -1,71 +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:web:trust-anchor.example.com", - "validFrom": "2024-01-15T00:00:00Z", - "validUntil": "2025-01-15T00:00:00Z", - "credentialSubject": { - "id": "did:web:participant.example.com", - "type": "harbour:LegalPerson", - "name": "Example Corporation GmbH", - "gxParticipant": { - "type": "gx:LegalPerson", - "gx:legalName": "Example Corporation GmbH", - "gx:registrationNumber": "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:web:trust-anchor.example.com:services:revocation-registry#a1b2c3d4e5f67890", - "type": "harbour:CRSetEntry", - "statusPurpose": "revocation" - } - ], - "evidence": [ - { - "type": "harbour:IssuanceEvidence", - "verifiablePresentation": { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiablePresentation"], - "holder": "did:web:participant.example.com", - "verifiableCredential": [ - { - "@context": [ - "https://www.w3.org/ns/credentials/v2", - "https://w3id.org/gaia-x/development#" - ], - "type": ["VerifiableCredential"], - "issuer": "did:web:notary.example.com", - "validFrom": "2024-01-10T00:00:00Z", - "credentialSubject": { - "id": "did:web:participant.example.com", - "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 deleted file mode 100644 index 178c5bc..0000000 --- a/examples/natural-person-credential.json +++ /dev/null @@ -1,58 +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:web:issuer.example.com", - "validFrom": "2024-01-15T00:00:00Z", - "validUntil": "2025-01-15T00:00:00Z", - "credentialSubject": { - "id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", - "type": "harbour:NaturalPerson", - "name": "Alice Smith", - "schema:givenName": "Alice", - "schema:familyName": "Smith", - "schema:email": "alice.smith@example.com", - "memberOf": "did:web:participant.example.com", - "gxParticipant": { - "type": "gx:Participant", - "schema:name": "Alice Smith" - } - }, - "credentialStatus": [ - { - "id": "did:web:issuer.example.com:services:revocation-registry#b2c3d4e5f6a78901", - "type": "harbour:CRSetEntry", - "statusPurpose": "revocation" - } - ], - "evidence": [ - { - "type": "harbour:EmailVerification", - "verifiablePresentation": { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiablePresentation"], - "holder": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", - "verifiableCredential": [ - { - "@context": ["https://www.w3.org/ns/credentials/v2"], - "type": ["VerifiableCredential"], - "issuer": "did:web:altme.io", - "validFrom": "2024-01-10T00:00:00Z", - "credentialSubject": { - "id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", - "type": "EmailPass", - "email": "alice.smith@example.com" - } - } - ] - } - } - ] -} 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/linkml/core.yaml b/linkml/core.yaml deleted file mode 100644 index d894658..0000000 --- a/linkml/core.yaml +++ /dev/null @@ -1,29 +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: - # Now this will resolve to core:id (https://w3id.org/reachhaven/harbour/core/v1/id) - 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 diff --git a/linkml/gaiax-domain.yaml b/linkml/gaiax-domain.yaml deleted file mode 100644 index dff046c..0000000 --- a/linkml/gaiax-domain.yaml +++ /dev/null @@ -1,144 +0,0 @@ -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. - -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/ - -default_prefix: harbour -default_range: string - -imports: - - linkml:types - - ./harbour - -# ========================================== -# Composition Slots -# ========================================== -# These slots link harbour outer nodes to Gaia-X inner blank nodes. - -slots: - gxParticipant: - description: > - Nested Gaia-X participant compliance data - (gx:LegalPerson or gx:Participant blank node). - slot_uri: harbour:gxParticipant - 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 - # ========================================== - # Harbour wraps Gaia-X participant types via composition. - # Gaia-X data lives in nested blank nodes (gxParticipant / - # gxServiceOffering) to keep gx closed shapes intact. - - 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. - class_uri: harbour:LegalPerson - slots: - - name - - gxParticipant - slot_usage: - name: - required: true - - NaturalPerson: - description: > - A natural person (individual) participating in the harbour ecosystem. - Gaia-X participant data is nested in the gxParticipant slot. - class_uri: harbour:NaturalPerson - slots: - - name - - gxParticipant - slot_usage: - name: - required: true - attributes: - givenName: - slot_uri: schema:givenName - range: string - familyName: - slot_uri: schema:familyName - range: string - email: - slot_uri: schema:email - range: string - memberOf: - description: Organization (LegalPerson) the natural person belongs to. - 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 - # ========================================== - - 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: 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: 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-core-credential.yaml b/linkml/harbour-core-credential.yaml new file mode 100644 index 0000000..50185a8 --- /dev/null +++ b/linkml/harbour-core-credential.yaml @@ -0,0 +1,670 @@ +id: https://w3id.org/reachhaven/harbour/core/v1 +name: harbour-core-credential +description: > + Base LinkML schema for Harbour credentials. + Defines the W3C VC envelope constraints (issuer, validFrom, + 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. harbour-gx-credential.yaml). + +# ============================================================================ +# 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 +# [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 +# 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 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 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/ + 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# + rdfs: http://www.w3.org/2000/01/rdf-schema# + harbour.delegate: https://w3id.org/reachhaven/harbour/delegate/v1/ + +default_prefix: harbour +default_range: string + +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. + # If present, MUST be a URL (incl. DIDs, urn:uuid:). + # Maps to JSON-LD @id. + # [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 + + # 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. + # [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: sec:controller + 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: 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: 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. + publicKeyJwk: + slot_uri: sec:publicKeyJwk + range: JsonLiteral + + # [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: uri + 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. + # [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 --- + # 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: sdo:contactType + range: string + + # [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] 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+jwt) or SD-JWT VP. + slot_uri: harbour:verifiablePresentation + range: HarbourVerifiablePresentation + required: false + + # --- Delegated Signature Evidence Slots --- + # [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). + # + # 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: JsonLiteral + required: false + + # [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 + +classes: + + # ========================================== + # 1. ROOT DOCUMENT + # ========================================== + # [DID-CORE] §4 — DID Document is a set of data describing the DID subject. + # Properties: id (REQUIRED), controller, verificationMethod, service, etc. + DIDDocument: + class_uri: didcore:DIDDocument + slots: + - controller + attributes: + authentication: + slot_uri: sec:authenticationMethod + multivalued: true + range: uri + assertionMethod: + 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: + slot_uri: didcore:service + multivalued: true + inlined: true + 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 + inlined: true + range: VerificationMethod + + # ========================================== + # 2. SERVICES + # ========================================== + # [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: + - 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: + - serviceEndpoint + slot_usage: + serviceEndpoint: + 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 + via did:serviceEndpoint rather than an xsd:anyURI literal. + + # Uses schema.org Organization vocabulary for interoperability. + OrganizationEndpoint: + class_uri: sdo:Organization + attributes: + name: + 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 + range: uri + description: + description: A human-readable description of the organization. + slot_uri: sdo:description + range: string + contactPoint: + description: A contact point for the organization. + slot_uri: sdo:contactPoint + range: ContactPoint + inlined: true + + ContactPoint: + class_uri: sdo:ContactPoint + slots: + - contactType + attributes: + email: + description: An email address. + slot_uri: sdo:email + range: string + + # Harbour-specific: CRSet revocation registry service endpoint. + # [VCDM2] §4.10 — credentialStatus mechanisms are extensible. CRSet is a + # Harbour-defined mechanism (not BitstringStatusList or StatusList2021). + CRSetRevocationRegistryService: + class_uri: harbour:CRSetRevocationRegistryService + slots: + - serviceEndpoint + slot_usage: + serviceEndpoint: + range: CRSetServiceEndpoint + inlined: true + + CRSetServiceEndpoint: + class_uri: harbour:CRSetServiceEndpoint + slots: + - registryEndpoint + - statusPurpose + - contractURN + - sourceRepository + - implementation + + # ========================================== + # 3. CREDENTIAL TYPES + # ========================================== + # [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. + # [VC-JOSE-COSE] §3.1.1 — full VC JSON-LD becomes JWT payload (no vc wrapper). + # [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 + description: > + Abstract base for all Harbour credentials. Requires issuer, validFrom, + and credentialStatus with at least one CRSetEntry for revocation support. + class_uri: harbour:Credential + slots: + - issuer + - validFrom + - validUntil + - credentialSubject + - evidence + - 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 + credentialSubject: + range: Any + 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 + # Harbour profile: credentialStatus REQUIRED with CRSetEntry range. + credentialStatus: + range: CRSetEntry + required: true + description: Status entries for revocation checking (CRSet). + + HarbourVerifiableCredential: + 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. + 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/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:Presentation + 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. + + HarbourVerifiablePresentation: + is_a: HarbourPresentation + description: > + Concrete presentation type at the core layer. Wraps one or more + HarbourVerifiableCredential instances for transmission. Evidence + VPs embedded in credentials use this type. Domain layers may define + specialized presentation types if needed. + 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/VerifiablePresentation" + + # ========================================== + # 4. EVIDENCE TYPES + # ========================================== + # [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. + # [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: > + 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 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 + 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 + slot_usage: + verifiablePresentation: + required: true + + # Harbour-specific evidence type for delegated signing flows. + # [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. + # [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: > + 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:SignatureEvidence + slots: + - verifiablePresentation + - delegatedTo + - transaction_data + - challenge + slot_usage: + verifiablePresentation: + required: true + delegatedTo: + 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). + # [SD-JWT-VC] §3.2 — "status" claim uses status_list sub-object (idx + uri). + CRSetEntry: + class_uri: harbour:CRSetEntry + 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 + # ========================================== + # [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. + # [VC-JOSE-COSE] §4.2 — verification method type MUST be JsonWebKey; key + # material MUST be in publicKeyJwk property. + VerificationMethod: + class_uri: didcore:VerificationMethod + slots: + - controller + attributes: + 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-core-delegation.yaml b/linkml/harbour-core-delegation.yaml new file mode 100644 index 0000000..442ee99 --- /dev/null +++ b/linkml/harbour-core-delegation.yaml @@ -0,0 +1,300 @@ +id: https://w3id.org/reachhaven/harbour/delegate/v1 +name: harbour-core-delegation +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 new file mode 100644 index 0000000..403a40c --- /dev/null +++ b/linkml/harbour-gx-credential.yaml @@ -0,0 +1,455 @@ +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 and + compliance-verified credential types for the Gaia-X flow. + harbour:LegalPerson extends gx:LegalPerson (inherits registrationNumber, + 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/ +# 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 +# ============================================================================ +# Inheritance over composition: Harbour participant types extend gx: base +# classes directly via is_a, placing them in the gx type hierarchy: +# +# gx:Participant (abstract) +# ├─ gx:LegalPerson (entity data — used in plain gx input VCs) +# └─ harbour:NaturalPerson (is_a: Participant) +# +# harbour:LegalPerson — pure compliance attestation type +# (no gx inheritance — entity data in referenced gx:LegalPerson VC) +# +# 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. +# ============================================================================ + +prefixes: + linkml: https://w3id.org/linkml/ + harbour: https://w3id.org/reachhaven/harbour/core/v1/ + harbour.gx: https://w3id.org/reachhaven/harbour/gx/v1/ + 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). + cred: https://www.w3.org/2018/credentials# + gx: https://w3id.org/gaia-x/development# + +default_prefix: harbour.gx +default_range: string + +imports: + - linkml:types + - ./harbour-core-credential + - gaia-x + +# ========================================== +# Composition Slot (used by simpulseid layer) +# ========================================== +# 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: + # [GX-ONT-PA] — gx:Participant is the abstract base for all + # participant types in the Gaia-X hierarchy. + participant: + description: > + 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 + + +classes: + # ========================================== + # 1. CREDENTIAL TYPES + # ========================================== + # 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. + + # ------------------------------------------ + # 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.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/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.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/gx/v1/NaturalPersonCredential" + slot_usage: + validFrom: + required: true + evidence: + required: true + + + # ========================================== + # 2. CREDENTIAL SUBJECT TYPES + # ========================================== + # 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: + description: > + 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: LabelLevel + 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/ + # 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. + 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 + + # ------------------------------------------ + # 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) use https://schema.org/ vocabulary. + # [GX-ONT] — email uses gx:email from the Gaia-X vocabulary. + + HarbourNaturalPerson: + is_a: Participant + description: > + A natural person (individual) in the harbour ecosystem. + 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, 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 + slot_usage: + 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 + required: true + # [SCHEMA-ORG] — https://schema.org/givenName + givenName: + description: First name / given name of the natural person. + slot_uri: sdo:givenName + range: string + required: true + # [SCHEMA-ORG] — https://schema.org/familyName + familyName: + description: Last name / family name of the natural person. + slot_uri: sdo:familyName + range: string + 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). + memberOf: + description: Organization (LegalPerson) the natural person belongs to. + slot_uri: sdo:memberOf + range: uri diff --git a/linkml/harbour.yaml b/linkml/harbour.yaml deleted file mode 100644 index d10bac5..0000000 --- a/linkml/harbour.yaml +++ /dev/null @@ -1,285 +0,0 @@ -id: https://w3id.org/reachhaven/harbour/credentials/v1 -name: harbour -description: > - Base LinkML schema for Harbour credentials. - Defines the W3C VC envelope constraints (issuer, validFrom, - 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). - -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# - schema: http://schema.org/ - -default_prefix: harbour -default_range: string - -imports: - - linkml:types - - ./core - -slots: - # --- DID Slots --- - controller: - slot_uri: https://www.w3.org/ns/did#controller - range: string - - serviceEndpoint: - slot_uri: https://www.w3.org/ns/did#serviceEndpoint - range: Any - required: true - - # --- Revocation Registry (CRSet) Slots --- - statusPurpose: - slot_uri: cs:statusPurpose - range: string - required: false - - registryEndpoint: - description: Service endpoint IRI for the CRSet. - slot_uri: harbour:registryEndpoint - range: uri - required: false - - contractURN: - slot_uri: harbour:contractURN - range: uri - required: false - - sourceRepository: - slot_uri: harbour:sourceRepository - range: uri - required: false - - implementation: - slot_uri: harbour:implementation - range: uri - required: false - - # --- Trust Anchor / Organization Slots --- - contactType: - slot_uri: schema:contactType - range: string - - name: - description: A human-readable name for the entity. - slot_uri: schema:name - range: string - - description: - description: A human-readable description of the entity. - slot_uri: schema:description - range: string - - url: - description: A URL associated with the entity. - slot_uri: schema:url - range: uri - - email: - description: An email address. - slot_uri: schema:email - range: string - - contactPoint: - description: A contact point for the entity. - slot_uri: schema:contactPoint - range: string - - # --- Credential / Evidence Slots --- - evidence: - slot_uri: cred:evidence - range: Evidence - multivalued: true - required: false - - 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). - slot_uri: harbour:verifiablePresentation - range: Any - required: false - - # --- Credential Envelope Slots --- - issuer: - slot_uri: cred:issuer - range: string - required: true - description: DID of the credential issuer. - - # --- W3C VC v2 Envelope Slots (harbour constrains these) --- - validFrom: - slot_uri: cred:validFrom - range: datetime - required: true - - validUntil: - slot_uri: cred:validUntil - range: datetime - required: false - -classes: - Any: - class_uri: linkml:Any - description: "Generic class for any type of value." - - # ========================================== - # 1. ROOT DOCUMENT - # ========================================== - DIDDocument: - class_uri: https://www.w3.org/ns/did#DIDDocument - slots: - - id - - controller - attributes: - service: - slot_uri: https://www.w3.org/ns/did#service - multivalued: true - inlined: true - range: ServiceUnion - verificationMethod: - slot_uri: https://www.w3.org/ns/did#verificationMethod - multivalued: true - range: VerificationMethod - - # ========================================== - # 2. SERVICES - # ========================================== - ServiceUnion: - union_of: - - TrustAnchorService - - CRSetRevocationRegistryService - - TrustAnchorService: - class_uri: harbour:TrustAnchorService - slots: - - id - - type - - serviceEndpoint - slot_usage: - serviceEndpoint: - range: OrganizationEndpoint - inlined: true - - OrganizationEndpoint: - class_uri: schema:Organization - slots: - - name - - url - - description - - contactPoint - slot_usage: - contactPoint: - range: ContactPoint - inlined: true - - ContactPoint: - class_uri: schema:ContactPoint - slots: - - contactType - - email - - CRSetRevocationRegistryService: - class_uri: harbour:CRSetRevocationRegistryService - slots: - - id - - type - - serviceEndpoint - slot_usage: - serviceEndpoint: - range: CRSetServiceEndpoint - inlined: true - - CRSetServiceEndpoint: - class_uri: harbour:CRSetServiceEndpoint - slots: - - registryEndpoint - - statusPurpose - - contractURN - - sourceRepository - - implementation - - # ========================================== - # 3. CREDENTIAL TYPES - # ========================================== - # Harbour credential types add mandatory credentialStatus (CRSetEntry) - # and optional evidence to W3C VerifiableCredential. - - HarbourCredential: - abstract: true - description: > - Abstract base for all Harbour credentials. Requires issuer, validFrom, - and credentialStatus with at least one CRSetEntry for revocation support. - class_uri: harbour:HarbourCredential - slots: - - id - - type - - issuer - - validFrom - - validUntil - - evidence - attributes: - credentialStatus: - slot_uri: cred:credentialStatus - range: CRSetEntry - multivalued: true - required: true - description: Status entries for revocation checking (CRSet). - - # ========================================== - # 4. EVIDENCE TYPES - # ========================================== - Evidence: - abstract: true - class_uri: cred:Evidence - slots: - - type - - EmailVerification: - 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 - slots: - - verifiablePresentation - slot_usage: - verifiablePresentation: - required: true - - CRSetEntry: - class_uri: harbour:CRSetEntry - slots: - - id - - type - - statusPurpose - - # ========================================== - # 5. HELPERS - # ========================================== - VerificationMethod: - class_uri: https://www.w3.org/ns/did#VerificationMethod - slots: - - id - - type - - controller - attributes: - blockchainAccountId: - slot_uri: harbour:blockchainAccountId - range: string 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 new file mode 100644 index 0000000..5793398 --- /dev/null +++ b/linkml/w3c-vc.yaml @@ -0,0 +1,157 @@ +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, 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. + +# ============================================================================ +# 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 +# +# 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: + 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 + +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. 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: uri + 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. + # [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. + + # [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. + # [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 + 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. + # [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. + + # [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: 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/mkdocs.yml b/mkdocs.yml index 5530aef..808ba27 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -64,9 +64,27 @@ nav: - Getting Started: - 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: + - 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 - CLI Reference: - Overview: cli/index.md - API Reference: - Python: api/python/index.md - TypeScript: api/typescript/index.md - - Architecture Decisions: decisions/ + - 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 diff --git a/pyproject.toml b/pyproject.toml index 549a176..6e711a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,17 +26,16 @@ dependencies = [ "joserfc>=1.0.0", "cryptography>=44.0.0", "base58>=2.1.0", - "sd-jwt>=0.10.0", + "pycryptodome>=3.20", ] [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", + "rdflib>=7.0.0", ] docs = [ "mkdocs>=1.6.0", @@ -54,23 +53,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/__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 d5eb294..0000000 --- a/src/python/credentials/claim_mapping.py +++ /dev/null @@ -1,371 +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/credentials/v1/" - -# Gaia-X namespace -GAIAX_NS = "https://w3id.org/gaia-x/development#" - -HARBOUR_LEGAL_PERSON_MAPPING = { - "vct": f"{HARBOUR_NS}LegalPersonCredential", - "claims": { - "credentialSubject.name": "name", - "credentialSubject.gxParticipant.gx:legalName": "legalName", - "credentialSubject.gxParticipant.gx:registrationNumber": "registrationNumber", - "credentialSubject.gxParticipant.gx:headquartersAddress": "headquartersAddress", - "credentialSubject.gxParticipant.gx:legalAddress": "legalAddress", - }, - "always_disclosed": ["iss", "vct", "iat", "exp", "name", "legalName"], - "selectively_disclosed": [ - "registrationNumber", - "headquartersAddress", - "legalAddress", - ], -} - -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"], -} - -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"], -} - -# Registry: VC type string → mapping dict -# Additional mappings can be registered at runtime -MAPPINGS: dict[str, dict] = { - "harbour:LegalPersonCredential": HARBOUR_LEGAL_PERSON_MAPPING, - "harbour:NaturalPersonCredential": HARBOUR_NATURAL_PERSON_MAPPING, - "harbour:ServiceOfferingCredential": HARBOUR_SERVICE_OFFERING_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 get_mapping_for_vc(vc: dict) -> dict | None: - """Find the matching mapping for a VC based on its type. - - 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 - - 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.""" - parts = path.split(".") - 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.""" - parts = path.split(".") - current = obj - for part in parts[:-1]: - if part not in current: - current[part] = {} - current = current[part] - current[parts[-1]] = value - - -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - - -def main() -> 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:") - for type_key, mapping in MAPPINGS.items(): - print(f" {type_key}") - print(f" vct: {mapping['vct']}") - - -if __name__ == "__main__": - main() diff --git a/src/python/credentials/example_signer.py b/src/python/credentials/example_signer.py index 79ecdc8..55a70a6 100644 --- a/src/python/credentials/example_signer.py +++ b/src/python/credentials/example_signer.py @@ -2,6 +2,16 @@ 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. + +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) @@ -26,9 +36,11 @@ from cryptography.hazmat.primitives.asymmetric.ec import ( SECP256R1, + EllipticCurvePrivateKey, EllipticCurvePrivateNumbers, EllipticCurvePublicNumbers, ) + from harbour.keys import PrivateKey, p256_public_key_to_did_key from harbour.signer import sign_vc_jose, sign_vp_jose @@ -55,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 = ( @@ -69,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"]), @@ -93,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 @@ -132,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) @@ -150,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) @@ -227,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) @@ -244,7 +367,22 @@ 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")) + ) + # 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: @@ -254,24 +392,28 @@ 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: - jwt_path = process_example(path, private_key, kid_vm, output_dir) - print(f" {path.name} -> {jwt_path.name}") + # 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, keyring=keyring + ) + 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/src/python/credentials/verify_signed_examples.py b/src/python/credentials/verify_signed_examples.py new file mode 100644 index 0000000..b3ebeee --- /dev/null +++ b/src/python/credentials/verify_signed_examples.py @@ -0,0 +1,197 @@ +"""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. 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 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.""" + + credentials: int = 0 + evidence_presentations: int = 0 + 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: + 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, resolver: KeyResolver) -> 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() + 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_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") + 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_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() + 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( + "No signed example directories found. Run `make story-sign` first." + ) + + total = VerificationCounts() + for signed_dir in signed_dirs: + 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( + "\nDone: " + 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/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/_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/delegation.py b/src/python/harbour/delegation.py new file mode 100644 index 0000000..3f05a17 --- /dev/null +++ b/src/python/harbour/delegation.py @@ -0,0 +1,550 @@ +"""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 +OID4VP-aligned transaction data object (§8.4). + +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 base64 +import hashlib +import json +import secrets +import sys +import time +from dataclasses import asdict, dataclass, field +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", + "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: + """OID4VP-aligned transaction data object for delegated signing. + + This object follows the OID4VP §8.4 transaction_data structure. + The challenge contains only a hash of this object for compactness. + + Attributes: + 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) + 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"]) + """ + + type: str + credential_ids: list[str] + nonce: str + 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, 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. + + 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( + type=data["type"], + credential_ids=data["credential_ids"], + nonce=data["nonce"], + 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 + 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, + txn: dict[str, Any], + *, + credential_ids: list[str] | None = None, + nonce: str | 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 (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) + 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 iat is None: + iat = int(time.time()) + + if credential_ids is None: + credential_ids = ["default"] + + return cls( + type=f"{TYPE_PREFIX}:{action}", + credential_ids=credential_ids, + nonce=nonce, + iat=iat, + txn=txn, + exp=exp, + description=description, + ) + + +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", + ... txn={"asset_id": "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 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. + + 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", + "\u2500" * 50, + "", + f" Action: {action_label}", + ] + + # Add transaction-specific fields + 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: + display_value = display_value[:37] + "..." + lines.append(f" {display_key}: {display_value}") + + lines.extend( + [ + "", + "\u2500" * 50, + f" Nonce: {transaction_data.nonce}", + f" Issued at: {transaction_data.iat}", + ] + ) + + if transaction_data.exp is not None: + lines.append(f" Expires: {transaction_data.exp}") + + if transaction_data.description: + lines.append(f" Details: {transaction_data.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 prefix + if not transaction_data.type.startswith(f"{TYPE_PREFIX}:"): + raise ChallengeError( + f"Invalid type: expected '{TYPE_PREFIX}:*', 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)" + ) + + # 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}s (max {max_age_seconds}s)") + + if age < -60: # Allow 1 minute clock skew + raise ChallengeError( + f"Transaction timestamp is in the future: iat={transaction_data.iat}" + ) + + # Check expiration if present + if transaction_data.exp is not None: + if now > transaction_data.exp: + raise ChallengeError(f"Transaction expired at {transaction_data.exp}") + + +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( + "--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" + ) + + # 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 + txn = {} + if args.asset_id: + txn["asset_id"] = args.asset_id + if args.price: + txn["price"] = args.price + if args.currency: + txn["currency"] = args.currency + if args.chain: + txn["chain"] = args.chain + if args.contract: + txn["contract"] = args.contract + if args.recipient: + txn["recipient"] = args.recipient + + exp = None + if args.exp_minutes: + exp = int(time.time()) + args.exp_minutes * 60 + + tx = TransactionData.create( + action=args.action, + txn=txn, + credential_ids=args.credential_ids, + description=args.desc, + exp=exp, + ) + + 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": + tx = TransactionData.from_json( + Path(args.json_file).read_text(encoding="utf-8") + ) + print(render_transaction_display(tx, args.service)) + + elif args.command == "verify": + 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("\u2713 Challenge is valid and matches transaction data") + else: + print( + "\u2717 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/generate_artifacts.py b/src/python/harbour/generate_artifacts.py new file mode 100644 index 0000000..d754234 --- /dev/null +++ b/src/python/harbour/generate_artifacts.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Generate downstream artifacts (OWL ontology, SHACL shapes, JSON-LD context) +from Harbour LinkML schemas. + +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. + +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. + +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 +from pathlib import Path + +from linkml.generators.jsonldcontextgen import ContextGenerator +from linkml.generators.owlgen import OwlSchemaGenerator +from linkml.generators.shaclgen import ShaclGenerator + +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", + "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"} + + +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, + deterministic=True, + normalize_prefixes=True, + use_native_uris=False, + importmap=importmap, + base_dir=base_dir, + ) + owl_text = owl_gen.serialize() + + (out_dir / f"{domain}.owl.ttl").write_text(owl_text, encoding="utf-8") + + 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( + shacl_gen.serialize(), encoding="utf-8" + ) + + ctx_gen = ContextGenerator( + schema, + mergeimports=False, + exclude_external_imports=True, + xsd_anyuri_as_iri=True, + normalize_prefixes=True, + deterministic=True, + importmap=importmap, + base_dir=base_dir, + ) + 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" + + # 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) + + (out_dir / f"{domain}.context.jsonld").write_text(ctx_text, encoding="utf-8") + + print(f"\nDone: {ARTIFACTS_DIR}/") + + +if __name__ == "__main__": + main() diff --git a/src/python/harbour/kb_jwt.py b/src/python/harbour/kb_jwt.py index c1b057b..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 = "~" @@ -52,10 +53,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() ) @@ -143,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 @@ -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() ) @@ -194,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/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/src/python/harbour/sd_jwt.py b/src/python/harbour/sd_jwt.py index 44d17fb..d99cfba 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,15 @@ 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 from harbour._crypto import import_private_key as _import_private_key from harbour._crypto import import_public_key as _import_public_key @@ -23,12 +31,78 @@ 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 _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, @@ -39,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: @@ -110,6 +164,55 @@ 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, @@ -118,13 +221,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. @@ -161,24 +268,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: @@ -186,9 +292,17 @@ 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/src/python/harbour/sd_jwt_vp.py b/src/python/harbour/sd_jwt_vp.py new file mode 100644 index 0000000..58425c2 --- /dev/null +++ b/src/python/harbour/sd_jwt_vp.py @@ -0,0 +1,732 @@ +"""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 logging +import sys +import time +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 +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, + 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 = { + "DelegatedSignatureEvidence", + "harbour:DelegatedSignatureEvidence", + "harbour:SignatureEvidence", + "harbour.delegate:SignatureEvidence", +} + + +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( + 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. Supported types: + - CredentialEvidence: prior credential/VP the issuer relied upon + - 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. + + 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]) + + 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": { + "@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 resolved_nonce: + vp_payload["nonce"] = resolved_nonce + + if resolved_audience: + vp_payload["aud"] = resolved_audience + + 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 + 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 + # 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(sd_material.encode("ascii")).digest() + ) + .rstrip(b"=") + .decode(), + } + + 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") + 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, + check_transaction_freshness: bool = False, + max_transaction_age_seconds: int = 300, +) -> 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. + 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: + - '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 + # 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"=") + .decode() + ) + + 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_nonce != expected_nonce: + raise VerificationError( + f"Nonce mismatch: expected {expected_nonce!r}, got {vp_nonce!r}" + ) + if kb_nonce != expected_nonce: + raise VerificationError("Nonce mismatch in KB-JWT") + + # 7. Verify audience + if expected_audience is not None: + if vp_audience != expected_audience: + raise VerificationError( + f"Audience mismatch: expected {expected_audience!r}, got {vp_audience!r}" + ) + if kb_audience != expected_audience: + raise VerificationError("Audience mismatch in KB-JWT") + + # 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") + } + + 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 + 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:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0 + + # 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/src/python/harbour/signer.py b/src/python/harbour/signer.py index 1cad361..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( @@ -39,7 +40,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 +69,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..aae45b9 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): @@ -35,7 +36,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 +60,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") @@ -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 @@ -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/python/harbour/x509.py b/src/python/harbour/x509.py index 35abf40..de15ee6 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 @@ -17,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 @@ -280,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/src/typescript/harbour/delegation.ts b/src/typescript/harbour/delegation.ts new file mode 100644 index 0000000..f81be63 --- /dev/null +++ b/src/typescript/harbour/delegation.ts @@ -0,0 +1,366 @@ +/** + * 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(""); +} + +/** + * 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. + * + * 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/kb-jwt.ts b/src/typescript/harbour/kb-jwt.ts index 5081284..9a3f5c3 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,12 +40,15 @@ 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]; - 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 @@ -56,9 +59,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 +93,7 @@ export class KbJwtVerificationError extends Error { export interface KbJwtVerifyOptions { expectedNonce: string; expectedAudience: string; - expectedTransactionData?: string[]; + expected_transaction_data?: string[]; } /** @@ -107,7 +110,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); @@ -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) { @@ -166,9 +174,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/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/src/typescript/harbour/sd-jwt-vp.ts b/src/typescript/harbour/sd-jwt-vp.ts new file mode 100644 index 0000000..ed381ac --- /dev/null +++ b/src/typescript/harbour/sd-jwt-vp.ts @@ -0,0 +1,625 @@ +/** + * 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 { + 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", + "harbour:SignatureEvidence", + "harbour.delegate:SignatureEvidence", +]); + +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); + } + } + + 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 } : {}), + ...(delegationBindings.evidence && delegationBindings.evidence.length > 0 + ? { evidence: delegationBindings.evidence } + : {}), + }, + iat: Math.floor(Date.now() / 1000), + }; + + if (options.holderDid) { + vpPayload.iss = options.holderDid; + } + if (resolvedNonce) { + vpPayload.nonce = resolvedNonce; + } + if (resolvedAudience) { + vpPayload.aud = resolvedAudience; + } + + // 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 + // RFC 9901 §4.3.1 — sd_hash over ~~...~~ + const sdMaterial = + issuerJwt + + SD_JWT_SEPARATOR + + selectedDisclosures.join(SD_JWT_SEPARATOR) + + 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 (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); + 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) + ) as Record; + + // 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) + ) as Record; + + // 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) + ) as Record; + + // 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 + // RFC 9901 §4.3.1 — sd_hash over ~~...~~ + const sdMaterial = + issuerJwt + + SD_JWT_SEPARATOR + + disclosures.join(SD_JWT_SEPARATOR) + + 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"); + } + + 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 (vpNonce !== options.expectedNonce) { + throw new VerificationError( + `Nonce mismatch: expected '${options.expectedNonce}', got '${vpNonce}'` + ); + } + if (kbNonce !== options.expectedNonce) { + throw new VerificationError("Nonce mismatch in KB-JWT"); + } + } + + // 7. Verify audience + if (options.expectedAudience !== undefined) { + if (vpAudience !== options.expectedAudience) { + throw new VerificationError( + `Audience mismatch: expected '${options.expectedAudience}', got '${vpAudience}'` + ); + } + if (kbAudience !== options.expectedAudience) { + throw new VerificationError("Audience mismatch in KB-JWT"); + } + } + + // 8. Process disclosures + 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") { + 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 result: SdJwtVpResult = { + credential: disclosedClaims, + }; + + if (typeof vpObj.holder === "string") result.holder = vpObj.holder; + if (Array.isArray(vpObj.evidence)) + result.evidence = vpObj.evidence as Record[]; + if (vpNonce) result.nonce = vpNonce; + if (vpAudience) result.audience = vpAudience; + + 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")); +} + +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/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/submodules/ontology-management-base b/submodules/ontology-management-base index 107b584..1b9226e 160000 --- a/submodules/ontology-management-base +++ b/submodules/ontology-management-base @@ -1 +1 @@ -Subproject commit 107b5840d18ab96ec369b910a810fb7175ee4dab +Subproject commit 1b9226e689848a1f15725d6b83b028f97dae8ada diff --git a/submodules/w3id.org b/submodules/w3id.org index 5351f81..d004a45 160000 --- a/submodules/w3id.org +++ b/submodules/w3id.org @@ -1 +1 @@ -Subproject commit 5351f818b76f1ce45de93dec3d0e33466b06dd85 +Subproject commit d004a45366f3b1dd6716b482c0b54fc5ecd6cdc1 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/fixtures/canonicalization-vectors.json b/tests/fixtures/canonicalization-vectors.json new file mode 100644 index 0000000..42c5b6c --- /dev/null +++ b/tests/fixtures/canonicalization-vectors.json @@ -0,0 +1,122 @@ +{ + "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 \u2014 minimal required fields", + "input": { + "type": "harbour.delegate:data.purchase", + "credential_ids": [ + "harbour_natural_person" + ], + "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: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": "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", + "credential_ids": [ + "org_credential" + ], + "transaction_data_hashes_alg": [ + "sha-256" + ], + "nonce": "ab12cd34", + "iat": 1771934400, + "exp": 1771935300, + "description": "Sign partnership agreement", + "txn": { + "document_hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "parties": [ + "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: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", + "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": "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", + "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: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: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/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 a8759cd..7520603 100644 --- a/tests/fixtures/sample-vc.json +++ b/tests/fixtures/sample-vc.json @@ -1,14 +1,14 @@ { "@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", - "issuer": "did:web:trust.harbour.example.com", + "issuer": "did:ethr:0x14a34:0x4d6246a7d1e60caa44b75e3af9b37ac8d6442774", "validFrom": "2025-08-06T10:15:22Z", "credentialSubject": { - "id": "did:web:participant.example.com", + "id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", "type": "harbour:LegalPerson", "name": "Example Corporation GmbH" } diff --git a/tests/interop/test_cross_runtime.py b/tests/interop/test_cross_runtime.py index c4095de..3557e31 100644 --- a/tests/interop/test_cross_runtime.py +++ b/tests/interop/test_cross_runtime.py @@ -1,10 +1,19 @@ """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 + +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 from harbour.verifier import verify_vc_jose, verify_vp_jose @@ -14,39 +23,49 @@ TS_DIR = Path(__file__).resolve().parents[2] / "src" / "typescript" / "harbour" +_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", str(tmp)], + 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() + finally: + tmp.unlink(missing_ok=True) + + 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: + 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.""" @@ -77,7 +96,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 = { @@ -93,7 +112,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" @@ -112,7 +131,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); """ @@ -128,7 +147,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""" @@ -137,7 +156,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); """ @@ -146,7 +165,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"] @@ -156,7 +175,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, @@ -199,7 +221,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); @@ -210,5 +232,183 @@ 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" + + +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_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" + + 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/conftest.py b/tests/python/credentials/conftest.py index a1aa744..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 @@ -29,7 +30,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 +61,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 deleted file mode 100644 index f05e498..0000000 --- a/tests/python/credentials/test_claim_mapping.py +++ /dev/null @@ -1,216 +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 ( - 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" - - -def _load_fixture(name: str) -> dict: - """Load a credential example from the examples directory.""" - with open(EXAMPLES_DIR / name) as f: - return json.load(f) - - -class TestHarbourLegalPersonMapping: - def test_vc_to_claims(self): - vc = _load_fixture("legal-person-credential.json") - mapping = MAPPINGS["harbour:LegalPersonCredential"] - 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["name"] == "Example Corporation GmbH" - 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") - assert "credentialStatus" in vc - status = vc["credentialStatus"][0] - 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).""" - vc = _load_fixture("legal-person-credential.json") - 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.""" - vc = _load_fixture("legal-person-credential.json") - subject = vc["credentialSubject"] - gx = subject["gxParticipant"] - assert gx["type"] == "gx:LegalPerson" - assert "gx:legalName" in gx - assert "gx:registrationNumber" in gx - # gx properties must NOT be on the outer node - assert "gx:legalName" not in subject - assert "gx:registrationNumber" not in subject - - def test_roundtrip(self): - 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"]["gxParticipant"]["gx:legalName"] - == vc["credentialSubject"]["gxParticipant"]["gx:legalName"] - ) - - -class TestHarbourNaturalPersonMapping: - def test_vc_to_claims(self): - 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 - - 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] - assert evidence["type"] == "harbour:EmailVerification" - - def test_subject_is_harbour_natural_person(self): - """Verify the subject uses harbour:NaturalPerson (outer node only).""" - vc = _load_fixture("natural-person-credential.json") - 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.""" - vc = _load_fixture("natural-person-credential.json") - subject = vc["credentialSubject"] - gx = subject["gxParticipant"] - assert gx["type"] == "gx:Participant" - - 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"]["schema:givenName"] - == vc["credentialSubject"]["schema:givenName"] - ) - - -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") - mapping = get_mapping_for_vc(vc) - assert mapping is not None - assert "LegalPersonCredential" in mapping["vct"] - - 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:web:issuer.example.com", - "validFrom": "2024-01-01T00:00:00Z", - "credentialSubject": { - "id": "did:web:subject.example.com", - "customField": "custom-value", - }, - "credentialStatus": [ - { - "id": "did:web:issuer.example.com: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/credentials/test_example_signer.py b/tests/python/credentials/test_example_signer.py index 24ad232..e40aa09 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, @@ -24,6 +25,7 @@ _REPO_ROOT = _REPO_ROOT.parent EXAMPLES_DIR = _REPO_ROOT / "examples" +GAIAX_EXAMPLES_DIR = EXAMPLES_DIR / "gaiax" @pytest.fixture(scope="module") @@ -57,15 +59,15 @@ 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:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", "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:web:participant.example.com", + "id": "did:ethr:0x14a34:0xa682b9044de0a1ad3429e8c6a0be0ed45d01da93", "type": "gx:LegalPerson", }, } @@ -101,7 +103,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"}, } @@ -113,7 +115,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 @@ -128,10 +130,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) @@ -148,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: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] @@ -160,35 +161,57 @@ 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.""" + 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 / "service-offering-credential.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) # 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() + 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:ServiceOfferingCredential" 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 "harbour:SignatureEvidence" in ev_type + else: + assert ev_type == "harbour:SignatureEvidence" + assert "transaction_data" in evidence + assert evidence["transaction_data"]["type"] == "harbour.delegate:data.purchase" + assert ( + evidence["delegatedTo"] + == "did:ethr:0x14a34:0x31f1ca3dc5da9f83f360d805662d11a418950202" + ) + + # 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.""" + """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("*.json")) + 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: @@ -196,3 +219,71 @@ 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.gx:LegalPersonCredential" in vc_payload["type"] + + # Subject should have compliance data (no entity data) + subject = vc_payload["credentialSubject"] + 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.""" + 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.gx:NaturalPersonCredential" in vc_payload["type"] + + # Subject should have the NaturalPerson data directly + subject = vc_payload["credentialSubject"] + 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.""" + 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_shacl_failures.py b/tests/python/credentials/test_shacl_failures.py new file mode 100644 index 0000000..b3f9212 --- /dev/null +++ b/tests/python/credentials/test_shacl_failures.py @@ -0,0 +1,671 @@ +"""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 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") +- 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 the OMB submodule. +""" + +import copy +import json +import sys +import tempfile +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" +) +_ARTIFACTS_DIR = _REPO_ROOT / "artifacts" +_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" / "validators" / "shacl" / "validator.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) + + +# --------------------------------------------------------------------------- +# OMB validation suite bootstrap +# --------------------------------------------------------------------------- + + +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.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, + ) + + +# --------------------------------------------------------------------------- +# Session-scoped fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +def shacl_validator(): + """OMB ShaclValidator with harbour artifacts registered.""" + return _make_validator() + + +# --------------------------------------------------------------------------- +# Validation helper +# --------------------------------------------------------------------------- + + +def _validate( + credential: dict, + validator, +) -> tuple[bool, list[ShaclViolation], str]: + """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) + """ + 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) + + +# --------------------------------------------------------------------------- +# 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, shacl_validator): + """A valid credential must pass SHACL validation with zero violations.""" + cred = _load_example(example_file) + 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" + 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, + shacl_validator, + ): + cred = _load_example(example_file) + mutated = _remove_field(cred, *field_path) + + conforms, violations, text = _validate(mutated, shacl_validator) + + # 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, + shacl_validator, + ): + cred = _load_example(example_file) + mutated = mutate_fn(cred) + + conforms, violations, text = _validate(mutated, shacl_validator) + + 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, + shacl_validator, + ): + 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, shacl_validator) + + 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, 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 + # 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, 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, shacl_validator): + """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, 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, shacl_validator): + """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, shacl_validator) + assert not conforms, ( + "Two labelLevel values should violate maxCount " + f"but SHACL said it conforms.\nFull report:\n{text}" + ) diff --git a/tests/python/credentials/test_sign_examples.py b/tests/python/credentials/test_sign_examples.py index 5dd6d88..3009f4d 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,7 +31,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() ) @@ -40,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"] diff --git a/tests/python/credentials/test_validation.py b/tests/python/credentials/test_validation.py index d9c0230..1b4c608 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: @@ -36,10 +36,22 @@ 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/ and examples/gaiax/.""" + files: list[Path] = [] if EXAMPLES_DIR.is_dir(): - return sorted(EXAMPLES_DIR.glob("*.json")) - return [] + files.extend( + p + for p in EXAMPLES_DIR.glob("*.json") + if any(t in p.stem for t in ("credential", "receipt", "offering")) + ) + 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) # --------------------------------------------------------------------------- @@ -47,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 @@ -62,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 @@ -72,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): @@ -98,31 +113,42 @@ 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 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) + # 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. 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( - "harbour:" - ), f"Subject type should be harbour-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("harbour:") for t in subject_type - ), f"Subject type list should include a harbour type: {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}" + ) # --------------------------------------------------------------------------- @@ -144,8 +170,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,69 +180,71 @@ 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: 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}" + ) # --------------------------------------------------------------------------- -# 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", {}) + has_vocab = "@vocab" in ctx domain_classes = [ "LegalPersonCredential", "NaturalPersonCredential", - "ServiceOfferingCredential", "LegalPerson", "NaturalPerson", - "ServiceOffering", ] for cls in domain_classes: - assert cls in ctx, f"Missing {cls} in gaiax-domain 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", {}) - assert "gxParticipant" in ctx, "Missing gxParticipant in domain context" - assert "gxServiceOffering" in ctx, "Missing gxServiceOffering 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", {}) + has_vocab = "@vocab" in ctx domain_classes = [ "LegalPerson", "NaturalPerson", - "ServiceOffering", "LegalPersonCredential", "NaturalPersonCredential", - "ServiceOfferingCredential", ] - 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 - ), f"{cls} has unprefixed @id without @vocab: {aid}" + assert has_vocab or ":" in aid, ( + f"{cls} has unprefixed @id without @vocab: {aid}" + ) # --------------------------------------------------------------------------- @@ -238,59 +266,62 @@ 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:EmailVerification", - "harbour:IssuanceEvidence", + "harbour:CredentialEvidence", + "harbour:SignatureEvidence", ] 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.""" + """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: 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.""" content = HARBOUR_SHACL_PATH.read_text() - for ev_type in ["EmailVerification", "IssuanceEvidence"]: - 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: 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" + ) # --------------------------------------------------------------------------- -# 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() @@ -299,17 +330,15 @@ 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:ServiceOfferingCredential", - "harbour:LegalPerson", - "harbour:NaturalPerson", - "harbour:ServiceOffering", + "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 - ), 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.""" @@ -317,33 +346,32 @@ def test_credential_shapes_have_required_properties(self): for cred_type in [ "LegalPersonCredential", "NaturalPersonCredential", - "ServiceOfferingCredential", ]: - 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) 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.""" 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) 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 new file mode 100644 index 0000000..5d9c4b5 --- /dev/null +++ b/tests/python/harbour/test_delegation.py @@ -0,0 +1,957 @@ +"""Tests for harbour.delegation module. + +This module tests the Harbour Delegated Signing Evidence Specification v2 +with OID4VP-aligned TransactionData. + +Tests cover: +- 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 + +import json +import time +from pathlib import Path +from unittest.mock import patch + +import pytest + +from harbour.delegation import ( + ACTION_LABELS, + 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, + verify_challenge, +) + +FIXTURES_DIR = Path(__file__).resolve().parents[2] / "fixtures" + + +# ============================================================================= +# TransactionData Tests +# ============================================================================= + + +class TestTransactionData: + """Tests for TransactionData dataclass.""" + + def test_create_basic(self): + """Test basic TransactionData creation.""" + tx = TransactionData.create( + action="data.purchase", + txn={"asset_id": "urn:uuid:test", "price": "100"}, + ) + + 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 + 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 isinstance(tx.iat, int) + + def test_create_with_custom_nonce(self): + """Test TransactionData with custom nonce.""" + tx = TransactionData.create( + action="data.purchase", + txn={"asset_id": "test"}, + nonce="custom123", + ) + + assert tx.nonce == "custom123" + + def test_create_with_custom_iat(self): + """Test TransactionData with custom iat.""" + tx = TransactionData.create( + action="data.purchase", + txn={"asset_id": "test"}, + iat=1771934400, + ) + + assert tx.iat == 1771934400 + + def test_create_with_optional_fields(self): + """Test TransactionData with optional fields.""" + tx = TransactionData.create( + action="data.purchase", + txn={"asset_id": "test"}, + exp=1771935300, + description="Test purchase", + credential_ids=["harbour_natural_person"], + ) + + assert tx.exp == 1771935300 + assert tx.description == "Test purchase" + assert tx.credential_ids == ["harbour_natural_person"] + + def test_action_property(self): + """Test action extraction from type field.""" + tx = TransactionData.create( + action="data.purchase", + txn={"asset_id": "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", + iat=1771934400, + txn={"asset_id": "test", "price": "100"}, + ) + + d = tx.to_dict() + + assert d["type"] == "harbour.delegate:data.purchase" + assert d["credential_ids"] == ["default"] + assert d["nonce"] == "da9b1009" + assert d["iat"] == 1771934400 + assert d["txn"] == {"asset_id": "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=["harbour_natural_person"], + nonce="da9b1009", + iat=1771934400, + txn={"asset_id": "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( + type="harbour.delegate:data.purchase", + credential_ids=["default"], + nonce="da9b1009", + iat=1771934400, + txn={"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( + type="harbour.delegate:data.purchase", + credential_ids=["default"], + nonce="da9b1009", + iat=1771934400, + txn={"asset_id": "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": "harbour.delegate:contract.sign", + "credential_ids": ["org_credential"], + "nonce": "ab12cd34", + "iat": 1771934400, + "exp": 1771935300, + "description": "Sign agreement", + "txn": {"document_hash": "sha256:abc123"}, + "transaction_data_hashes_alg": ["sha-256"], + } + + tx = TransactionData.from_dict(data) + + assert tx.type == "harbour.delegate:contract.sign" + assert tx.credential_ids == ["org_credential"] + assert tx.nonce == "ab12cd34" + assert tx.iat == 1771934400 + assert tx.exp == 1771935300 + assert tx.description == "Sign agreement" + assert tx.txn["document_hash"] == "sha256:abc123" + + def test_from_json(self): + """Test TransactionData.from_json().""" + json_str = json.dumps( + { + "type": "harbour.delegate:data.purchase", + "credential_ids": ["default"], + "nonce": "abc12345", + "iat": 1771934400, + "txn": {"asset_id": "test"}, + } + ) + + 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", + 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.iat == original.iat + assert restored.txn == original.txn + assert restored.description == original.description + assert restored.credential_ids == original.credential_ids + + +class TestHashComputation: + """Tests for hash computation.""" + + def test_compute_hash_deterministic(self): + """Test that hash computation is deterministic.""" + tx1 = TransactionData( + type="harbour.delegate:data.purchase", + credential_ids=["default"], + nonce="da9b1009", + iat=1771934400, + txn={"asset_id": "test", "price": "100"}, + ) + + tx2 = TransactionData( + type="harbour.delegate:data.purchase", + credential_ids=["default"], + nonce="da9b1009", + iat=1771934400, + txn={"asset_id": "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( + type="harbour.delegate:data.purchase", + credential_ids=["default"], + nonce="da9b1009", + iat=1771934400, + txn={"asset_id": "test", "price": "100"}, + ) + + tx2 = TransactionData( + type="harbour.delegate:data.purchase", + credential_ids=["default"], + nonce="da9b1009", + iat=1771934400, + txn={"price": "100", "asset_id": "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", + txn={"asset_id": "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( + type="harbour.delegate:data.purchase", + credential_ids=["default"], + nonce="da9b1009", + iat=1771934400, + txn={"asset_id": "test", "price": "100"}, + ) + + tx2 = TransactionData( + type="harbour.delegate:data.purchase", + credential_ids=["default"], + nonce="da9b1009", + iat=1771934400, + txn={"asset_id": "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 = { + "type": "harbour.delegate:data.purchase", + "credential_ids": ["default"], + "nonce": "da9b1009", + "iat": 1771934400, + "txn": {"asset_id": "test"}, + } + + base_tx = TransactionData(**base) + base_hash = base_tx.compute_hash() + + # Test each field change produces different hash + variations = [ + {"type": "harbour.delegate:data.share"}, + {"credential_ids": ["other"]}, + {"nonce": "different"}, + {"iat": 9999999999}, + {"txn": {"asset_id": "other"}}, + ] + + for change in variations: + modified = {**base, **change} + modified_tx = TransactionData(**modified) + assert modified_tx.compute_hash() != base_hash, ( + 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']}" + ) + + 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 +# ============================================================================= + + +class TestCreateDelegationChallenge: + """Tests for create_delegation_challenge().""" + + def test_basic_challenge(self): + """Test basic challenge creation.""" + tx = TransactionData( + type="harbour.delegate:data.purchase", + credential_ids=["default"], + nonce="da9b1009", + iat=1771934400, + txn={"asset_id": "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( + type="harbour.delegate:data.purchase", + credential_ids=["default"], + nonce="da9b1009", + iat=1771934400, + txn={"asset_id": "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", + txn={"asset_id": "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( + type="harbour.delegate:data.purchase", + credential_ids=["default"], + nonce="da9b1009", + iat=1771934400, + txn={"asset_id": "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( + type="harbour.delegate:data.purchase", + credential_ids=["default"], + nonce="da9b1009", + iat=1771934400, + txn={"asset_id": "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( + type="harbour.delegate:data.purchase", + credential_ids=["default"], + nonce="da9b1009", + iat=1771934400, + txn={"asset_id": "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( + type="harbour.delegate:data.purchase", + credential_ids=["default"], + nonce="da9b1009", + iat=1771934400, + txn={"asset_id": "test", "price": "100"}, + ) + + challenge = create_delegation_challenge(tx) + + # Tamper with transaction data + tx.txn["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", + txn={"asset_id": "test"}, + ) + + # Should not raise + validate_transaction_data(tx) + + def test_validate_invalid_type(self): + """Test validation fails for invalid type prefix.""" + tx = TransactionData( + type="wrong_prefix:data.purchase", + credential_ids=["default"], + nonce="da9b1009", + iat=int(time.time()), + txn={"asset_id": "test"}, + ) + + 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( + type="harbour.delegate:data.purchase", + credential_ids=["default"], + nonce="abc", # Too short (< 8 chars) + iat=int(time.time()), + txn={"asset_id": "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_iat = int(time.time()) - 600 # 10 minutes ago + tx = TransactionData( + type="harbour.delegate:data.purchase", + credential_ids=["default"], + nonce="da9b1009", + iat=old_iat, + txn={"asset_id": "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_iat = int(time.time()) + 300 # 5 minutes in future + tx = TransactionData( + type="harbour.delegate:data.purchase", + credential_ids=["default"], + nonce="da9b1009", + iat=future_iat, + txn={"asset_id": "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.""" + now = int(time.time()) + tx = TransactionData.create( + action="data.purchase", + txn={"asset_id": "test"}, + exp=now - 300, # Expired 5 minutes ago + ) + + 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_iat = int(time.time()) - 120 + tx = TransactionData( + type="harbour.delegate:data.purchase", + credential_ids=["default"], + nonce="da9b1009", + iat=old_iat, + txn={"asset_id": "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( + type="harbour.delegate:data.purchase", + credential_ids=["default"], + nonce="da9b1009", + iat=1771934400, + txn={ + "asset_id": "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 "1771934400" in display + + def test_render_custom_service_name(self): + """Test display with custom service name.""" + tx = TransactionData.create( + action="data.purchase", + txn={"asset_id": "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( + type="harbour.delegate:unknown.action", + credential_ids=["default"], + nonce="da9b1009", + iat=1771934400, + txn={"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", + txn={"asset_id": "test"}, + exp=1771935300, + ) + + display = render_transaction_display(tx) + + assert "Expires:" in display + assert "1771935300" in display + + def test_render_with_description(self): + """Test display includes description if present.""" + tx = TransactionData.create( + action="data.purchase", + txn={"asset_id": "test"}, + 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", + txn={"asset_id": "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, + txn={"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", + txn={ + "asset_id": "urn:uuid:550e8400-e29b-41d4-a716-446655440000", + "price": "100", + "currency": "ENVITED", + }, + description="Purchase sensor data", + credential_ids=["harbour_natural_person"], + ) + + # 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", + txn={"document_hash": "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", + txn={"asset_id": f"asset-{i}"}, + ) + hashes.add(tx.compute_hash()) + + # All hashes should be unique + assert len(hashes) == 10 diff --git a/tests/python/harbour/test_kb_jwt.py b/tests/python/harbour/test_kb_jwt.py index 3993d30..dbfc718 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, @@ -11,12 +12,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:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", + "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/core/v1/LegalPersonCredential" @pytest.fixture() 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 7ed76d5..a0196b3 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, @@ -10,16 +11,16 @@ from harbour.verifier import VerificationError SAMPLE_CLAIMS = { - "iss": "did:web:did.ascs.digital:participants:ascs", + "iss": "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", "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/core/v1/LegalPersonCredential" class TestSDJWTVCIssuance: @@ -67,8 +68,11 @@ 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:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c" + ) def test_verify_with_selective_disclosure(self, p256_private_key, p256_public_key): sd_jwt = issue_sd_jwt_vc( @@ -79,10 +83,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 +139,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 +151,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): @@ -165,3 +169,181 @@ 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 new file mode 100644 index 0000000..300c316 --- /dev/null +++ b/tests/python/harbour/test_sd_jwt_vp.py @@ -0,0 +1,746 @@ +"""Tests for SD-JWT VP (Verifiable Presentations with selective disclosure).""" + +import base64 +import json +import secrets + +import pytest +from joserfc import jws + +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 + + +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 +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) + + # SD-JWT-VC claims (flat or nested per RFC 9901 §6) + claims = { + "iss": "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", + "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( + claims, + private_key, + vct="https://example.com/MembershipCredential", + disclosable=["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:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0", + ) + + # 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 + tx_nonce = "tx-consent-nonce" + audience = "did:ethr:0x14a34:0xab645824d16971b89a2243f21881864ad57b9166" + + evidence = [ + { + "type": "harbour:SignatureEvidence", + "transaction_data": { + "type": "harbour.delegate:data.purchase", + "credential_ids": ["harbour_natural_person"], + "nonce": tx_nonce, + "iat": 1771934400, + "txn": {"asset_id": "tx:abc123", "price": "100"}, + }, + "delegatedTo": audience, + } + ] + + vp = issue_sd_jwt_vp( + sample_sd_jwt_vc, + holder_private, + evidence=evidence, + nonce=tx_nonce, + audience=audience, + ) + + # Parse VP/KB payloads to check evidence-derived bindings + parts = vp.split("~") + vp_jwt = parts[0] + 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"] == "harbour:SignatureEvidence" + 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": "harbour:SignatureEvidence", + "transaction_data": { + "type": "harbour.delegate:data.purchase", + "credential_ids": ["default"], + "nonce": "snake-nonce", + "iat": 1771934400, + "txn": {"asset_id": "tx:snake"}, + }, + } + ] + + 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.""" + 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:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0" + + 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": "harbour:SignatureEvidence", + "transaction_data": { + "type": "harbour.delegate:blockchain.approve", + "credential_ids": ["default"], + "nonce": "unique-consent-nonce", + "iat": 1771934400, + "txn": {"contract": "0x1234"}, + }, + } + ] + + 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"] == "harbour:SignatureEvidence" + + 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": "harbour:SignatureEvidence", + "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:ethr:0x14a34:0xab645824d16971b89a2243f21881864ad57b9166", + ) + parts = vp.split("~") + kb_payload = _decode_jwt_payload(parts[-1]) + 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]) + + 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": "harbour:SignatureEvidence", + "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 + _, 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:ethr:0x14a34:0x3c85cd0f7f6333319143a1b21306a3339445ad0a", + ) + + with pytest.raises(VerificationError, match="Audience mismatch"): + verify_sd_jwt_vp( + vp, + issuer_public, + holder_public, + expected_audience="did:ethr:0x14a34:0x33d113d8e2426612046f29da322c159855de0ba0", + ) + + +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 + claims = { + "iss": "did:ethr:0x14a34:0xb0771a9447399cd33e0cad1228a33ac914715105", + "sub": holder_did, + "givenName": "Carlo", + "familyName": "Rossi", + "organization": "BMW", + "role": "Purchaser", + } + + sd_jwt_vc = issue_sd_jwt_vc( + claims, + issuer_private, + vct="https://example.com/IdentityCredential", + disclosable=["givenName", "familyName"], # PII is selectively disclosable + ) + + # Step 2: Holder creates consent VP + signing_service_did = ( + "did:ethr:0x14a34:0x1d19e55b17c018b6704b8217c95975d97e531269" + ) + consent_nonce = secrets.token_urlsafe(32) + + transaction_data = { + "type": "harbour.delegate:data.purchase", + "credential_ids": ["harbour_natural_person"], + "nonce": consent_nonce, + "iat": 1771934400, + "description": "Purchase data asset XYZ for 100 ENVITED tokens", + "txn": { + "asset_id": "tx:0xabc123def456", + "price": "100", + "currency": "ENVITED", + }, + } + + evidence = [ + { + "type": "harbour:SignatureEvidence", + "transaction_data": transaction_data, + "delegatedTo": signing_service_did, + } + ] + + challenge_nonce = consent_nonce + + # 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 data + assert len(result["evidence"]) == 1 + ev = result["evidence"][0] + 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( + TransactionData.from_dict(transaction_data) + ) + 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 + claims = { + "iss": "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", + "sub": holder_did, + "name": "Confidential Person", + "email": "secret@example.com", + "publicRole": "Authorized Purchaser", + } + + sd_jwt_vc = issue_sd_jwt_vc( + claims, + issuer_private, + vct="https://example.com/VerifiableCredential", + disclosable=["name", "email"], # PII hidden by default + ) + + # Create VP with no PII disclosed + evidence = [ + { + "type": "harbour:SignatureEvidence", + "transaction_data": { + "type": "harbour.delegate:blockchain.transfer", + "credential_ids": ["default"], + "nonce": "public-audit-nonce", + "iat": 1771934400, + "txn": {"recipient": "0x123", "amount": "1000"}, + }, + } + ] + + 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"] == "harbour:SignatureEvidence" + + # 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 + ) + + # 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] + payload = json.loads( + base64.urlsafe_b64decode(payload_b64 + "=" * (-len(payload_b64) % 4)) + ) + + 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": "harbour:SignatureEvidence", + "transaction_data": { + "type": "harbour.delegate:data.share", + "credential_ids": ["default"], + "nonce": "multi-evidence-nonce", + "iat": 1771934400, + "txn": {"resource_id": "asset:xyz"}, + }, + }, + {"type": "harbour:CredentialEvidence", "verifiablePresentation": "eyJ..."}, + ] + + 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 diff --git a/tests/python/harbour/test_sign.py b/tests/python/harbour/test_sign.py index 8f2febf..1caf039 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): @@ -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 aecb4c8..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,7 +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:web:did.ascs.digital:participants:evil" + payload["credentialSubject"]["id"] = ( + "did:ethr:0x14a34:0x81c6d42b1781bb3bb7a280f564d66ec9d41beace" + ) tampered_payload = ( base64.urlsafe_b64encode( json.dumps(payload, ensure_ascii=False).encode("utf-8") @@ -49,7 +52,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..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 @@ -62,17 +63,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 +88,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..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, @@ -108,8 +109,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 +123,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/delegation.test.ts b/tests/typescript/harbour/delegation.test.ts new file mode 100644 index 0000000..b157461 --- /dev/null +++ b/tests/typescript/harbour/delegation.test.ts @@ -0,0 +1,461 @@ +/** + * 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, + computeTransactionDataParamHash, + computeTransactionHash, + createDelegationChallenge, + createTransactionData, + encodeTransactionDataParam, + 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: { 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({ asset_id: "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: { asset_id: "test" }, + nonce: "custom123", + }); + expect(tx.nonce).toBe("custom123"); + }); + + it("creates with custom iat", () => { + const tx = createTransactionData({ + action: "data.purchase", + txn: { asset_id: "test" }, + iat: 1771934400, + }); + expect(tx.iat).toBe(1771934400); + }); + + it("creates with optional fields", () => { + const tx = createTransactionData({ + action: "data.purchase", + txn: { asset_id: "test" }, + exp: 1771935300, + description: "Test purchase", + credentialIds: ["harbour_natural_person"], + }); + + expect(tx.exp).toBe(1771935300); + expect(tx.description).toBe("Test purchase"); + expect(tx.credential_ids).toEqual(["harbour_natural_person"]); + }); + + it("extracts action from type", () => { + const tx = createTransactionData({ + action: "data.purchase", + txn: { asset_id: "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: { asset_id: "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: { asset_id: "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", asset_id: "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: { asset_id: "test", price: "100" }, + transaction_data_hashes_alg: ["sha-256"], + }; + + const tx2: TransactionData = { + ...tx1, + txn: { asset_id: "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); + }); + + 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); + }); + } +}); + +// ============================================================================= +// 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: { asset_id: "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: { asset_id: "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: { asset_id: "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: { asset_id: "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: { asset_id: "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: { asset_id: "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: { asset_id: "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: { asset_id: "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: { asset_id: "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: { asset_id: "test" }, + transaction_data_hashes_alg: ["sha-256"], + }; + expect(() => validateTransactionData(tx)).toThrow(/future/); + }); + + it("throws for expired transaction", () => { + const tx = createTransactionData({ + action: "data.purchase", + txn: { asset_id: "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: { asset_id: "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/kb-jwt.test.ts b/tests/typescript/harbour/kb-jwt.test.ts index 141cef8..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,15 +63,15 @@ describe("KB-JWT creation", () => { const withKb = await createKbJwt(sdJwt, holderPrivateKey, { nonce: "test-nonce", - audience: "did:web:verifier.example.com", - transactionData: ["tx1", "tx2"], + 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", - expectedTransactionData: ["tx1", "tx2"], + expectedAudience: "did:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0", + 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 new file mode 100644 index 0000000..e6eb7d8 --- /dev/null +++ b/tests/typescript/harbour/sd-jwt-vp.test.ts @@ -0,0 +1,416 @@ +/** + * Tests for SD-JWT VP (Verifiable Presentations with selective disclosure). + */ + +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, + 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; + +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; + 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:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", + 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:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0", + }); + + 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 txNonce = "tx-consent-nonce"; + const audience = "did:ethr:0x14a34:0xab645824d16971b89a2243f21881864ad57b9166"; + const evidence = [ + { + type: "harbour:SignatureEvidence", + transaction_data: { + type: "harbour.delegate:data.purchase", + credential_ids: ["harbour_natural_person"], + nonce: txNonce, + iat: 1771934400, + txn: { asset_id: "tx:abc123", price: "100" }, + }, + delegatedTo: audience, + }, + ]; + + const vp = await issueSdJwtVp(sampleSdJwtVc, holderPrivate, { + evidence, + nonce: txNonce, + audience, + }); + + // Parse VP/KB payloads to check evidence-derived bindings + const parts = vp.split("~"); + 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( + "harbour:SignatureEvidence" + ); + 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: "harbour:SignatureEvidence", + 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 () => { + 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:ethr:0x14a34:0x6c6ddd7fb6c9732f30734a63db7e257987aed0e0"; + + 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: "harbour:SignatureEvidence", + transaction_data: { + 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("harbour:SignatureEvidence"); + }); + + it("fails when transaction_data_hashes is tampered", async () => { + const nonce = "tx-hash-nonce"; + const evidence = [ + { + type: "harbour:SignatureEvidence", + 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:ethr:0x14a34:0xab645824d16971b89a2243f21881864ad57b9166", + }); + const parts = vp.split("~"); + const kbPayload = decodeJwtPayload(parts[parts.length - 1]); + kbPayload.aud = "did:ethr:0x14a34:0x81c6d42b1781bb3bb7a280f564d66ec9d41beace"; + 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: "harbour:SignatureEvidence", + 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); + + 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:ethr:0x14a34:0x62ed6f3003261ad826c5c4adae4934072f772dae", + }); + + await expect( + verifySdJwtVp(vp, issuerPublic, holderPublic, { + expectedAudience: "did:ethr:0x14a34:0x62f7f3546fdd7c013d1f206179d867c13bcb47da", + }) + ).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"); + }); +}); diff --git a/tests/typescript/harbour/sd-jwt.test.ts b/tests/typescript/harbour/sd-jwt.test.ts index 0dff9ff..5637023 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/core/v1/LegalPersonCredential"; const SAMPLE_CLAIMS = { - iss: "did:web:did.ascs.digital:participants:ascs", + iss: "did:ethr:0x14a34:0x212025b9751231b17ead53fdcaad8ddeffa0106c", 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..ee925bb 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,17 +80,17 @@ 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 () => { 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 a987af6..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:did.ascs.digital:participants:evil"; + "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); }); diff --git a/tests/validation-probe/ontology-loading-probe.json b/tests/validation-probe/ontology-loading-probe.json new file mode 100644 index 0000000..7ea0e47 --- /dev/null +++ b/tests/validation-probe/ontology-loading-probe.json @@ -0,0 +1,12 @@ +{ + "@context": { + "@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/core/v1/LoadProbe", + "https://w3id.org/reachhaven/harbour/core/v1/LoadProbe", + "https://w3id.org/gaia-x/development#LoadProbe" + ] +}