# DIDs (did:jwk, did:key, did:web)

Helix can accept SD-JWT VC issuers identified by **decentralised identifiers (DIDs)**, publish a realm's own `did:web` document as a credential issuer, and accept DID-named holder keys in issuance proofs. (Preview, not yet tested with a real national wallet.)

!!! warning "Status: Preview — not yet tested with a real national wallet"
    New in 1.1.0-rc.1. Tested with the project's own test wallet and Helix's own issuer and verifier only.

!!! note "Outside the EUDI framework only"
    The EU Architecture and Reference Framework and HAIP trust PID and (Q)EAA providers through **X.509** chains and trusted lists. That remains the default and the only thing an EU wallet ecosystem needs; EU wallets and EUDI verifiers do not look up DIDs. DID support is for ecosystems outside that framework that identify issuers by DIDs. It is opt-in, and **a DID is never trusted because it resolves**: only DIDs an administrator lists are accepted.

## Supported methods

| Method | Resolution | Used for |
| --- | --- | --- |
| `did:jwk` | offline (the key is in the DID) | accepted issuers; holder keys in issuance proofs |
| `did:key` (P-256) | offline | accepted issuers; holder keys in issuance proofs |
| `did:web` | over https, see [below](#didweb-resolution) | accepted issuers; the realm's own issuer DID |

Only ES256 (P-256) keys: an Ed25519 / EdDSA key, P-384 or RSA is refused with a message that says so.

## Accepting DID issuers (verifier)

A [wallet connection](eudi-wallet.md) lists the DIDs it accepts:

- `didIssuers`: the PID's issuers (Terraform `did_issuers` on `helix_wallet_identity_provider`);
- `credentials[].didIssuers`: each [other credential](wallet-credentials.md)'s issuers (Terraform: `didIssuers` inside `credentials`).

Each is a JSON array of at most 20 entries. An entry is either:

- a **full DID**: `did:web:issuer.example[:path]`, `did:jwk:…`, `did:key:z…`;
- or a **bare lower-case domain** (`issuer.example`), which accepts **only** `did:web:issuer.example` itself: no port, no path, no wildcards.

```json
"config": {
  "credentialFormats": "[\"dc+sd-jwt\"]",
  "didIssuers": "[\"issuer.example\", \"did:web:id.example:realms:acme\"]"
}
```

SD-JWT VC only: an mdoc credential cannot list DIDs, and a PID that lists DIDs needs `dc+sd-jwt` in `credentialFormats`. With DIDs, the PID or credential may have no X.509 anchors at all.

!!! warning "List path DIDs exactly"
    One host can publish many did:web DIDs — every Helix realm is `did:web:<host>:realms:<realm>`. A domain entry therefore never covers a DID with a path. A domain entry naming this server's own host (the host of `IDP_BASE_URL` or one in `HELIX_ALLOWED_HOSTS`) is refused with `400`: it would let the administrator of any other realm on this server publish a did:web whose credentials you accept, even as a PID. List the exact realm DID instead — and only for a credential besides the PID: a realm DID of this deployment (or a `did:jwk` / `did:key` of one of its issuer keys) is refused for the PID. See [This deployment's own issuer](wallet-credentials.md#this-deployments-own-issuer).

Editing a list is an identity-provider save: it goes through the same permission checks as trust anchors and trusted lists.

### Resolve and show keys

The console's **DID issuers** field (under the trust anchors, and per credential) has **Resolve and show keys**: it resolves a listed DID through the server's resolver and shows each verification method, whether it can verify credentials, and why not. Nothing is stored.

```bash
curl -s -b cookies.txt -c cookies.txt "$HELIX_URL/admin/realms/$REALM/identity-providers" >/dev/null
CSRF=$(awk '($6=="__Host-XSRF-TOKEN"||$6=="XSRF-TOKEN")&&$7~/^r1\./{t=$7} END{print t}' cookies.txt)

curl -s -b cookies.txt -H "Content-Type: application/json" -H "X-XSRF-TOKEN: $CSRF" \
  -d '{"did":"did:web:issuer.example"}' \
  "$HELIX_URL/admin/realms/$REALM/wallet/did/resolve"
```

Send one DID; a bare domain entry names no single DID and is refused with a `400` on `did`. It needs `manage-identity-providers` and is limited to 20 checks a minute per administrator. Every network failure — a private or blocked host, an unknown host, TLS, HTTP errors, timeouts — gives the same "could not be resolved" answer (the reason is in the server log), so the check cannot be used to map internal DNS.

### How a DID-issued credential is verified

A credential with `x5c` takes the X.509 path exactly as before. Only a credential **without** `x5c` whose header `kid` is a DID URL takes the DID path:

1. the DID must be on this credential type's allow-list — checked **before** anything is resolved, so an unlisted `did:web` causes no network request (`ISSUER_UNTRUSTED`);
2. the DID is resolved, and `kid` must name a verification method in `assertionMethod` (`ISSUER_UNTRUSTED` otherwise) with an EC P-256 key (`ALGORITHM_NOT_ALLOWED` otherwise);
3. `alg` must be ES256 and the signature must verify (`ISSUER_SIGNATURE_INVALID`);
4. `iss` must be present and equal the DID;
5. type, validity, disclosures, key binding and status as for every SD-JWT VC. The **status list** of a DID-issued credential must be signed by a key of the same, still listed DID; an X.509-issued credential's list must chain to the anchors as before, never by DID.

The audit records the DID as the issuer. With DIDs configured, the DCQL query of that PID or credential carries no `trusted_authorities`, because the `aki` hint names X.509 anchors and would make a wallet withhold a DID-issued credential.

### did:web resolution

`did:web:host[%3Aport][:path…]` resolves to `https://host[:port]/path…/did.json` (no path: `/.well-known/did.json`).

- Hosts must be DNS names (IP literals are refused).
- The fetch goes through the outbound guard: private, loopback, link-local and metadata addresses are refused unless `helix.egress.allowed-private-hosts` lists the host; https only; **no redirect is followed**; connect 3 s, 5 s in total including DNS, at most 64 KiB.
- The document is parsed strictly and its `id` must be the DID.
- Successful resolutions are cached for the smaller of the response's `Cache-Control` max-age and `helix.did.web.cache-max-ttl` (default 300 s, at most 1 h); failures are not cached.

Settings: `helix.did.web.connect-timeout`, `helix.did.web.total-timeout`, `helix.did.web.max-bytes`, `helix.did.web.cache-max-ttl`, `helix.did.web.cache-max-entries`.

## The issuer's did.json

A realm that [issues credentials](credential-issuance.md) can publish a did:web document: issuer settings → **did:web** → **Publish did:web** (`publishDidWeb: true` on `PUT /admin/realms/{realm}/credential-issuance/settings`, realm-admin; Terraform `helix_credential_issuer.publish_did_web`). The DID is derived from the credential issuer identifier — configuration, never the request's `Host`:

| Issuer identifier | DID | Document |
| --- | --- | --- |
| `https://id.example/realms/acme` | `did:web:id.example:realms:acme` | `https://id.example/realms/acme/did.json` |
| `https://id.example:8443/realms/acme` | `did:web:id.example%3A8443:realms:acme` | `https://id.example:8443/realms/acme/did.json` |

```bash
curl -s "$HELIX_URL/realms/$REALM/did.json"
```

Helix serves `GET /realms/{realm}/did.json` (`application/did+json`, public, `Cache-Control: max-age=300`, also while the realm is disabled) only while the realm issues **and** publishes; otherwise `404`. It lists one `JsonWebKey2020` verification method per issuer key that is PENDING, ACTIVE or ROTATED, with id `<did>#<RFC 7638 thumbprint>`, all in `assertionMethod`. A RETIRED key is withdrawn, so credentials it signed stop verifying with DID-resolving verifiers.

The console shows the DID, the URL and a live preview of the document (`GET /admin/realms/{realm}/credential-issuance/did-document`), also before you publish.

- An issuer identifier whose host is an IP address has no did:web.
- The host-wide `/.well-known/did.json` (a DID without a path) is **not** served: one host serves many realms. Map it in your reverse proxy if you need it.
- A template signs with the did:web key when its signing method is **did:web key id** (`signingMethod: "did_web"`); see [Credential issuance](credential-issuance.md#didweb-signing).

## DID holder keys (issuance)

When a wallet collects a credential, it may name its proof key with `kid` = a `did:jwk` or `did:key` DID URL instead of a `jwk` header. It is resolved offline, must be EC P-256, and the credential's `cnf.jwk` is that key. **`did:web` holder keys are refused**: the issuer would fetch a URL the wallet chose, and a holder key that can change under the credential defeats holder binding. The issuer metadata advertises `cryptographic_binding_methods_supported: ["jwk", "did:jwk", "did:key"]` for SD-JWT VC configurations.

## Not in this release

`did:ebsi`, `did:peer`, `did:ion` and universal resolvers; EdDSA keys; DID-based mdoc issuers; DID document proofs or Data Integrity signatures; DID-based wallet or client identification; `did:web` holder keys; the host-wide `/.well-known/did.json`. A did:web is only as strong as control of its domain and TLS certificate: for qualified or high-assurance use, stay with X.509 and trusted lists.

## See also

- [EU Digital Identity Wallet (OpenID4VP)](eudi-wallet.md) · [Credentials beyond the PID](wallet-credentials.md)
- [Credential issuance (OpenID4VCI)](credential-issuance.md)
