WhisperDocs
Standards

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 (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) 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:

$ 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:

$ 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:

$ 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. The fields that matter:

{
  "@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"
    }
  ]
}

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 record or the reverse-DNS PTR that already name the agent. See Identity: the address is the credential 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), 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:

# 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 .
# 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:

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 (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:

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
# 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:

# 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 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; for the CLI and control-plane calls that create the agent this document describes, see Quickstart.


Next: Identity: the address is the credential · DANE & DNSSEC