# did:web & verifiable credentials

**A portable web identity, resolved straight from the address - no registrar, no platform account, no app to install.**

Most "verifiable" agent identity today means: create an account with a vendor, mint a credential in their console, and hope whoever needs to check it also has an account with the same vendor. That's not verification, it's a membership card. The moment the vendor is unreachable, or the checker never signed up, the whole scheme collapses to "trust me." `did:web` fixes this by making the identity document a normal HTTPS resource at a name you already control - fetchable with `curl`, verifiable with any JOSE library, no account required on either end. Whisper publishes one automatically for every agent, at the agent's own name, the moment it exists.

## The problem it solves

A [Decentralized Identifier](https://www.w3.org/TR/did-core/) (DID) is a URI that resolves to a **DID document** - a small JSON object listing the public keys that speak for that identity and how to use them. The DID Core spec (W3C Recommendation) defines the document shape but deliberately leaves *resolution* to pluggable "methods": `did:key` embeds the key in the identifier itself (no resolution needed, but no revocation either), `did:ion`/`did:ethr` resolve against a blockchain (durable, but you now depend on a chain), and **`did:web`** ([W3C CCG did:web method spec](https://w3c-ccg.github.io/did-method-web/)) resolves against plain DNS + HTTPS - the identity lives wherever the name already points.

For an agent, that property is exactly what matters: the DID resolves through **the same name the agent already has**, so there is nothing new to register, host, or trust.

## The did:web method, precisely

A `did:web` identifier is the domain, colon-separated in place of slashes:

```
did:web:acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online
```

Resolution is a fixed, four-step transform from identifier to URL (per the spec):

1. Replace `:` with `/` in the method-specific-id to get a path: `acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online`.
2. If the identifier carried no path segment, append `/.well-known`.
3. Append `/did.json`.
4. Percent-decode any `%3A` (an encoded port) back to `:`, prepend `https://`, and `GET` it.

So `did:web:acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online` resolves to exactly one URL:

```
https://acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online/.well-known/did.json
```

No registry lookup, no smart contract, no extra round trip - the resolver *is* an HTTPS GET. (A path-form DID, e.g. `did:web:example.com:agents:42`, maps to `https://example.com/agents/42/did.json` instead - Whisper always uses the bare-host form so the DID is just the agent's FQDN with a prefix.)

### That GET needs `-k`, and the reason is the point

The agent serves this document under a certificate issued by the **Whisper Agent Identity Issuing CA**, which is in no public root store, and **whose trust is a DANE `TLSA 3 1 1` pin on the leaf key in the DNSSEC-signed zone**, not WebPKI. So a bare `curl` fails, and it should:

```console
$ curl -s https://{agent-fqdn}/.well-known/did.json
curl: (60) SSL certificate problem: unable to get local issuer certificate
```

That is not the endpoint being broken. There is no CA in this path *on purpose*: a CA is a third party you would have to trust, and the whole claim of this document is that you do not have to. The pin in DNS is the anchor, and it is stronger, because it is published by the zone the DID names and signed by DNSSEC up to the IANA root.

Check the pin, then read the document. Both halves, against a live agent:

```console
$ dig +short TLSA _443._tcp.{agent-fqdn}
3 1 1 2DB40608165945A70966CFE376A68A7F1735D44E2080CF8C27235324 C8EC0CF0

$ openssl s_client -connect {agent-fqdn}:443 -servername {agent-fqdn} </dev/null 2>/dev/null \
    | openssl x509 -pubkey -noout \
    | openssl pkey -pubin -outform DER \
    | openssl dgst -sha256 -binary | xxd -p -c 64
2db40608165945a70966cfe376a68a7f1735d44e2080cf8c27235324c8ec0cf0
```

The two match, so the key serving that name is the key DNS says should serve it, and no certificate authority was consulted. Now `-k` is not skipping a check, it is declining a check you have already done better:

```console
$ curl -sk https://{agent-fqdn}/.well-known/did.json | jq -r '.id'
did:web:{agent-fqdn}
```

`whisper verify --trustless` does all of the above in one call, including the DNSSEC chain from the root, and is what you should reach for in a script. The manual form is here because a claim you cannot check by hand is a claim you are taking on faith.

## The DID document

The document at that URL is JSON-LD conforming to [DID Core §5](https://www.w3.org/TR/did-core/#core-properties). The fields that matter:

```json
{
  "@context": [
    "https://www.w3.org/ns/did/v1",
    "https://w3id.org/security/suites/jws-2020/v1"
  ],
  "id": "did:web:acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online",
  "verificationMethod": [
    {
      "id": "did:web:acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online#17b67fbdf403e4b2ab785e89c4c3e54f997a44d5f1494e854b0e960d6a0a0795",
      "type": "JsonWebKey2020",
      "controller": "did:web:acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online",
      "publicKeyJwk": {
        "kty": "EC",
        "crv": "P-256",
        "alg": "ES256",
        "use": "sig",
        "kid": "17b67fbdf403e4b2ab785e89c4c3e54f997a44d5f1494e854b0e960d6a0a0795",
        "x": "O9i2cY2aCwrCXog8Ebso5Hc-gmdzXF66mZwWklB-EwA",
        "y": "2Yx22BDHARIOTk6rwtbmQVl6xtwGYq3RNou6zhDc4KI"
      }
    },
    {
      "id": "did:web:acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online#b653a4ef11618b6604d5e0a4bc1ccf2d68a7e5c269cd2ae890743c52fcb82d1d",
      "type": "JsonWebKey2020",
      "controller": "did:web:acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online",
      "publicKeyJwk": {
        "kty": "EC",
        "crv": "P-256",
        "alg": "ES256",
        "use": "sig",
        "kid": "b653a4ef11618b6604d5e0a4bc1ccf2d68a7e5c269cd2ae890743c52fcb82d1d",
        "x": "QNFaDOntgK38hm1xfH2ijxeItjUW0_tCTEtCPAG-A7c",
        "y": "e3yc6Jj_NlnR09KWeNUGGrcn3quxxsU5qPIff5R0pZA"
      }
    }
  ],
  "authentication": ["did:web:acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online#17b67fbdf403e4b2ab785e89c4c3e54f997a44d5f1494e854b0e960d6a0a0795"],
  "assertionMethod": ["did:web:acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online#b653a4ef11618b6604d5e0a4bc1ccf2d68a7e5c269cd2ae890743c52fcb82d1d"],
  "alsoKnownAs": [
    "urn:x-ip:2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4/128",
    "https://rdap.whisper.online/ip/2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4"
  ],
  "service": [
    {
      "id": "did:web:acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online#whisper-identity",
      "type": "LinkedDomains",
      "serviceEndpoint": "https://acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online/.well-known/whisper-identity"
    },
    {
      "id": "did:web:acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online#rdap",
      "type": "LinkedDomains",
      "serviceEndpoint": "https://rdap.whisper.online/ip/2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4"
    }
  ]
}
```

- **`verificationMethod`** - the public key(s) that speak for this DID, each a NIST **P-256** (`secp256r1`) key as a `JsonWebKey2020` (`alg: ES256`). Whisper publishes **two**, one per role below, each fragment-identified by its own key id rather than a shared `#key-1`: the `authentication` key's `kid` is the same one the agent's apex `/.well-known/jwks.json` publishes and the `whisper-identity` JWT signs with, so a DID consumer and a bare-JWKS consumer resolve to a byte-identical key; the `assertionMethod` key's `kid` is, deliberately, the same SHA-256 hash the [DANE `TLSA`](/docs/dane) record pins - its `publicKeyJwk` **is** the TLS leaf's own public key, so a Verifiable Credential this agent signs is checkable against the exact key a `dig +dnssec TLSA` lookup already proved. P-256/ES256 is the curve used throughout the identity stack - the DANE-pinned leaf and the [per-agent CA](/docs/per-agent-ca) live on it too - so a verifier's existing ES256 code path handles every door onto the identity.
- **`authentication`** - which verification method the agent uses to authenticate itself in a challenge/response.
- **`assertionMethod`** - which verification method the agent uses to *sign statements* - this is the one a Verifiable Credential issuer uses, and it's the DANE-pinned TLS key, not a separate signing-only key.
- **`alsoKnownAs`** - the agent's other public identifiers for the *same* subject: its `/128` as a `urn:x-ip:…/128` and its [RDAP](/docs/rdap) object URL. This is what ties the DID to the address - a resolver reading the DID document learns the exact IPv6 identity and where to fetch its registry record, all cross-checkable.
- **`service`** - where to fetch the JWS-signed [`whisper-identity`](/docs/identity) assertion and the RDAP object (both `LinkedDomains`, both public). A DID-aware verifier follows these to reach the rest of the proof set without any Whisper-specific knowledge.
- No private key material ever appears here - `did.json` is a public document, safe to cache, mirror, or CDN.

Because it's just DNS + HTTPS, the document inherits DNSSEC and TLS's existing trust chain: forging it means either compromising the agent's TLS certificate or its DNS delegation - the same bar as forging the [DANE `TLSA`](/docs/dane) record or the reverse-DNS PTR that already name the agent. See [Identity: the address is the credential](/docs/identity) for how the FQDN, PTR, TLSA, RDAP, and `did:web` document all resolve back to the same `/128` independently - that redundancy is the point: no single record is a single point of failure.

## Dual example: fetch the DID document

**With stock tools** - a `did:web` document is nothing but JSON over HTTPS, so any HTTP client resolves it. One real caveat: the agent's leaf is DANE-pinned (see [DANE & TLSA](/docs/dane)), not WebPKI-issued, so a plain `curl` with no extra trust source fails the TLS handshake (`unable to get local issuer certificate`) rather than fetching the document. Trust the same root your browser would with zero setup - fetch it once, it works for every agent - and the fetch succeeds:

```bash
# fetch and trust the Whisper agent CA root once (zero-config, published for exactly this)
curl -o whisper-ca.pem https://whisper.online/.well-known/whisper-ca.pem

curl -s --cacert whisper-ca.pem \
  https://acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online/.well-known/did.json | jq .
```

```bash
# or resolve the DID string itself with a generic resolver, e.g. did-resolver (npm) / did:web py:
python3 -c "
from did_web_resolver import resolve  # any spec-compliant resolver
print(resolve('did:web:acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online'))
"
```

**With Whisper** - the `did.json` keys are only worth trusting if the *name* serving them is provably the agent's. `whisper verify --trustless` proves exactly that, anchored at the IANA DNSSEC root and trusting no Whisper API - its `dane` check validates the served TLS leaf against the DNSSEC-signed `TLSA` pin, and that leaf's public key is byte-for-byte the `assertionMethod` entry the DID document publishes. So one keyless call establishes the trust the credential-signing half of the DID document rests on:

```bash
whisper verify --trustless acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online
# cryptographically walks DNSSEC (IANA root) → AAAA/PTR → DANE-EE TLSA →
# the DNSSEC-anchored identity signing key; Whisper's API is never trusted

curl -s https://rdap.whisper.online/verify-identity/2a04:2a01:eb5a:ca74:cef2:2a:323d:40d4 | jq .
# keyless, no account: the full-chain verdict as JSON (this endpoint trusts Whisper to run the chain)
```

## Issuing and verifying a Verifiable Credential

A [Verifiable Credential](https://www.w3.org/TR/vc-data-model-2.0/) (VC) is a signed statement - "this DID asserts X" - and `did:web` gives you the one thing a VC verifier needs: a way to fetch the issuer's public key from the DID alone. The common wire form is a compact JWS (a "VC-JWT"): a JSON Web Token whose payload embeds the credential and whose `kid` header points at a `verificationMethod` in the issuer's `did.json`.

```
header:  {"alg":"ES256","kid":"did:web:acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online#b653a4ef11618b6604d5e0a4bc1ccf2d68a7e5c269cd2ae890743c52fcb82d1d","typ":"JWT"}
payload: {"iss":"did:web:acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online",
          "vc":{"@context":["https://www.w3.org/ns/credentials/v2"],
                "type":["VerifiableCredential","AgentCapability"],
                "credentialSubject":{"scope":"read:inventory"}},
          "nbf":1751328000,"exp":1751414400}
signature: ES256 over header.payload, by the assertionMethod key
```

The `kid` in the header is the credential's own pointer to which `verificationMethod` signed it - here, the `assertionMethod` key (the one whose `kid` matches the DANE `TLSA` pin, not the `authentication` one). Verification is three steps, and none of them touch Whisper: split the JWT, resolve the `kid` fragment to a `publicKeyJwk` by matching it against `verificationMethod[].id` in the DID document (never assume array position - a document can list its keys in either order), and check the ES256 signature against it.

**With stock tools** - any JOSE library does this without knowing Whisper exists:

```python
import json, ssl, urllib.request
from jose import jws
from jose.utils import base64url_decode

# the leaf is DANE-pinned, not WebPKI-issued (see /docs/dane) - trust the published root
ctx = ssl.create_default_context(cafile="whisper-ca.pem")

fqdn = "acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online"
doc = json.load(urllib.request.urlopen(f"https://{fqdn}/.well-known/did.json", context=ctx))
kid = jws.get_unverified_header(credential_jwt)["kid"].split("#")[-1]
jwk = next(vm["publicKeyJwk"] for vm in doc["verificationMethod"] if vm["id"].endswith(kid))

payload = jws.verify(credential_jwt, jwk, algorithms=["ES256"])
print(json.loads(payload))   # raises jose.exceptions.JWSError if the signature is bad
```

```bash
# equivalent one-liner with step-cli, no Python needed - the credential names its own key,
# so pick the matching verificationMethod rather than a fixed array index:
kid=$(step crypto jwt inspect --insecure <<<"$credential_jwt" | jq -r '.header.kid | split("#")[-1]')
step crypto jwt verify \
  --jwks <(curl -s --cacert whisper-ca.pem "https://$fqdn/.well-known/did.json" \
           | jq --arg kid "$kid" '{keys:[.verificationMethod[] | select(.id | endswith($kid)) | .publicKeyJwk]}') \
  --token "$credential_jwt"
```

**With Whisper** - checking the credential's *signature* is a JOSE-library job (above); Whisper's job is the harder half a JOSE library can't do on its own - proving the key you pulled from `did.json` genuinely belongs to that agent and wasn't swapped by whoever served the document. `whisper verify --trustless` settles that independently of Whisper's API, so you resolve the issuer key once, confirm it, then verify every credential that issuer signs against it:

```bash
# 1. establish trust in the issuer DID's key (DNSSEC-anchored, no Whisper API trusted)
whisper verify --trustless acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online

# 2. then verify the VC-JWT against the matching did:web key with any JOSE tool (as above)
step crypto jwt verify \
  --jwks <(curl -s --cacert whisper-ca.pem \
           https://acef2002a323d40d4.t7af9242c44554e0f8e183ac8e6fbf645.agents.whisper.online/.well-known/did.json \
           | jq --arg kid "$kid" '{keys:[.verificationMethod[] | select(.id | endswith($kid)) | .publicKeyJwk]}') \
  --token "$credential_jwt"
```

> `did:web`'s honest limitation: availability of the document depends on the host staying up, exactly like any HTTPS resource. Whisper's answer is the same one it gives for every other identity record - the document is served from the two independent, active/active authoritative nodes behind `agents.whisper.online`, not a single origin, so "the host is down" isn't a failure mode you inherit.

## Where this fits

`did:web` is an additional, W3C-native door onto the very identity the [seven independent proofs](/docs/identity) already establish - reverse DNS, forward DNS, DANE, RDAP/WHOIS, and the signable identity document, all resolving back to the same `/128`. Its `alsoKnownAs` and `service` entries link straight back into that set (the `/128`, the RDAP object, the `whisper-identity` assertion), so the DID isn't a parallel identity - it's the same one, reshaped for a different ecosystem. And it's the door built specifically for W3C identity/credentials tooling: wallets, verifier SDKs, and agent frameworks that already speak DID/VC can consume a Whisper agent's identity with zero Whisper-specific code. For the full trustless chain-of-custody (DNSSEC → DANE → transparency log) that backs the key in that document, see [DANE & DNSSEC](/docs/dane); for the CLI and control-plane calls that create the agent this document describes, see [Quickstart](/docs/quickstart).

---

**Next:** [Identity: the address is the credential](/docs/identity) · [DANE & DNSSEC](/docs/dane)
