# 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.demo.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.demo.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.demo.agents.whisper.online` resolves to exactly one URL:

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

No registry lookup, no smart contract, no extra round trip — the resolver *is* an HTTPS GET, over a TLS session anchored the normal way. (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.)

## 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.demo.agents.whisper.online",
  "verificationMethod": [{
    "id": "did:web:acef2002a323d40d4.demo.agents.whisper.online#key-1",
    "type": "JsonWebKey2020",
    "controller": "did:web:acef2002a323d40d4.demo.agents.whisper.online",
    "publicKeyJwk": {
      "kty": "EC",
      "crv": "P-256",
      "alg": "ES256",
      "use": "sig",
      "kid": "key-1",
      "x": "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4",
      "y": "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFGM"
    }
  }],
  "authentication": ["did:web:acef2002a323d40d4.demo.agents.whisper.online#key-1"],
  "assertionMethod": ["did:web:acef2002a323d40d4.demo.agents.whisper.online#key-1"],
  "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.demo.agents.whisper.online#whisper-identity",
      "type": "LinkedDomains",
      "serviceEndpoint": "https://acef2002a323d40d4.demo.agents.whisper.online/.well-known/whisper-identity"
    },
    {
      "id": "did:web:acef2002a323d40d4.demo.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. Whisper publishes a NIST **P-256** (`secp256r1`) key as a `JsonWebKey2020` (`alg: ES256`); the `#key-1` fragment is the same key id (`kid`) the agent's apex `/.well-known/jwks.json` publishes, so a DID consumer and a bare-JWKS consumer resolve to a byte-identical key. 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.
- **`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:

```bash
curl -s https://acef2002a323d40d4.demo.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.demo.agents.whisper.online'))
"
```

**With Whisper** — the `did.json` key is only worth trusting if the *name* serving it is provably the agent's. `whisper verify --trustless` proves exactly that, anchored at the IANA DNSSEC root and trusting no Whisper API — and the key it validates (the DNSSEC-anchored ES256 signing key) is byte-for-byte the one the DID document publishes. So one keyless call establishes the trust the DID document rests on:

```bash
whisper verify --trustless acef2002a323d40d4.demo.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.demo.agents.whisper.online#key-1","typ":"JWT"}
payload: {"iss":"did:web:acef2002a323d40d4.demo.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 key-1
```

Verification is three steps, and none of them touch Whisper: split the JWT, resolve `did:web:…#key-1` to a `publicKeyJwk` via the algorithm above, and check the ES256 signature against it.

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

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

fqdn = "acef2002a323d40d4.demo.agents.whisper.online"
doc = json.load(urllib.request.urlopen(f"https://{fqdn}/.well-known/did.json"))
jwk = doc["verificationMethod"][0]["publicKeyJwk"]

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:
step crypto jwt verify --jwks <(curl -s https://$fqdn/.well-known/did.json | jq '{keys:[.verificationMethod[0].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.demo.agents.whisper.online

# 2. then verify the VC-JWT against the resolved did:web key with any JOSE tool (as above)
step crypto jwt verify \
  --jwks <(curl -s https://acef2002a323d40d4.demo.agents.whisper.online/.well-known/did.json \
           | jq '{keys:[.verificationMethod[0].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)
