# Sign & prove agent outputs

**A report, a commit, an API response — anything your agent produces can carry a signature the recipient checks against a key pinned in DNS, not a "trust me" footer.**

An agent finishes a job — a security scan, a generated report, a code change, a JSON payload handed to another service — and hands over the result. How does the recipient know *which* agent produced it, and that it hasn't been altered since? Today the answer is usually nothing: a filename, a `From:`-style header, maybe a Slack message saying "done." None of that is checkable by anyone who wasn't in the room. If the output later matters — an audit, a dispute, a downstream agent deciding whether to act on it — "the agent said so" is a rumor with good formatting, not evidence. That gets worse at scale, not better: a fleet of short-lived agents producing outputs continuously means a constant stream of unverifiable claims, with no way to tell a real one from a forged one after the fact.

Whisper doesn't add a new signing product to solve this. It already gives your agent the one thing that makes a signature checkable by a stranger: a **DNSSEC-anchored name** — the same name its [`did:web` document](/docs/did-web) and [DANE `TLSA` record](/docs/dane) already resolve identity against.

So the idea here isn't a new key; it's a new *use* of the anchor you already have. Your agent signs its output with its own signing key, publishes that key's public half **at its Whisper name** — the same DNSSEC-signed DNS place that anchors its identity keys — and the recipient verifies against DNS: no CA, no vendor dashboard, no shared secret.

One name, one DNSSEC-rooted chain, now covering both "who is this agent" and "did this agent really produce this." (It's the same shape Whisper already ships for SSH keys — you supply the key, Whisper publishes its fingerprint under your name; see [`SSHFP`](#bonus-signing-commits-with-sshfp) below.)

## The mechanism: a detached JWS over a content hash

The wire format is [JSON Web Signature](https://www.rfc-editor.org/rfc/rfc7515) (JWS, RFC 7515) — the same envelope behind every OAuth access token, wrapped around a small **manifest** instead of the whole file. For anything bigger than a few KB you don't want to base64 the entire artifact into the token; you sign a claim about it instead:

```json
// header (protected)
{ "alg": "ES256", "kid": "did:web:acef2002a323d40d4.demo.agents.whisper.online#sig-1", "typ": "JOSE" }

// payload
{ "iss": "did:web:acef2002a323d40d4.demo.agents.whisper.online",
  "sub": "report.md",
  "sha256": "86e7530bd0fe9762072958f37b8655872d3fde3e80f5ece15767255c2d7c5fef",
  "iat": 1782864000 }
```

`alg: ES256` is ECDSA over P-256 with SHA-256, per [RFC 7518 §3.4](https://www.rfc-editor.org/rfc/rfc7518#section-3.4) — a 64-byte fixed-width `R‖S` signature, **not** the DER-encoded signature OpenSSL produces by default. That mismatch is the single most common JWS bug: `openssl dgst -sign` gives you ASN.1 DER; a JWS verifier expects raw concatenated integers. Use a JOSE library for the envelope (it does the DER↔raw conversion for you) and reserve raw `openssl` for what it's already good at — hashing the artifact and, separately, checking the DANE pin. The three parts — `base64url(header)`, `base64url(payload)`, `base64url(signature)` — join with `.` into the familiar compact form, and the whole thing ships as a sidecar: `report.md` next to `report.md.jws`.

`kid` is the load-bearing field: it's a `did:web` URL, so "the key that made this signature" isn't an opaque ID you look up in a vendor's dashboard — it's [an HTTPS GET away](/docs/did-web). `#sig-1` is *your agent's own* output-signing key: a `verificationMethod` your agent publishes at its name, right alongside the `#key-1` identity key its DID document already serves (or, if you'd rather not touch the DID doc, a DKIM-style JWK `TXT` under the same name — see the `SSHFP` section for that exact publish pattern). Either way it lands in the agent's DNSSEC-signed zone, so resolving the `kid` yields a key rooted in DNS, bound to the same `/128` that produced the output. See [Identity: the address is the credential](/docs/identity) for why that binding — address → name → key — can't be forged from one side alone.

## Dual example: sign it, then verify it two ways

**With stock tools — sign** (any JOSE library; here `jwcrypto`, `pip install jwcrypto`):

```python
import hashlib, json, time
from jwcrypto import jwk, jws
from jwcrypto.common import json_encode

digest = hashlib.sha256(open("report.md", "rb").read()).hexdigest()
manifest = json.dumps({
    "iss": "did:web:acef2002a323d40d4.demo.agents.whisper.online",
    "sub": "report.md", "sha256": digest, "iat": int(time.time()),
}).encode()

key = jwk.JWK.from_pem(open("agent-signing-key.pem", "rb").read())  # the agent's own signing key
token = jws.JWS(manifest)
token.add_signature(key, alg="ES256", protected=json_encode(
    {"alg": "ES256", "kid": "did:web:acef2002a323d40d4.demo.agents.whisper.online#sig-1", "typ": "JOSE"}))
open("report.md.jws", "w").write(token.serialize(compact=True))
```

`agent-signing-key.pem` is an ordinary P-256 key your agent generates once and keeps for itself — `openssl ecparam -genkey -name prime256v1` is the whole ceremony. It's not a Whisper-issued key and not the agent's TLS key; the only Whisper-specific step is publishing its *public* half at the agent's name (the `#sig-1` `verificationMethod` above), so a recipient can find it by DNS. No CA, no enrollment.

**With stock tools — verify** (the recipient needs nothing but the JWS and a resolver; no account anywhere):

```python
import hashlib, json, urllib.request
from jwcrypto import jwk, jws

fqdn = "acef2002a323d40d4.demo.agents.whisper.online"
kid  = f"did:web:{fqdn}#sig-1"                        # the key named in the JWS header
doc  = json.load(urllib.request.urlopen(f"https://{fqdn}/.well-known/did.json"))
vm   = next(m for m in doc["verificationMethod"] if m["id"] == kid)   # resolve the exact key
pub  = jwk.JWK(**vm["publicKeyJwk"])

token = jws.JWS()
token.deserialize(open("report.md.jws").read())
token.verify(pub, alg="ES256")                       # raises if the signature is bad
claim = json.loads(token.payload)
assert claim["sha256"] == hashlib.sha256(open("report.md", "rb").read()).hexdigest()
print(f"OK — {claim['sub']} was produced by {claim['iss']}, content unaltered")
```

Two independent checks, both mandatory: the **signature** proves the manifest came from the key at `sig-1`; the **hash comparison** proves `report.md` is the exact bytes that manifest describes. Either one failing means reject — a valid signature over a stale hash is exactly as useless as a valid hash with no signature.

**With Whisper** — the gap that `urllib.request.urlopen` above leaves open is trust in the *name*: it fetches `did.json` over plain HTTPS and believes whoever answered. `whisper verify --trustless` closes it, re-deriving the whole chain from the DNSSEC root in-process instead of taking the server's word (see [Verify an agent](/docs/verify)), so you prove the name is genuinely this agent's `/128` **before** you trust any key it publishes:

```bash
whisper verify --trustless acef2002a323d40d4.demo.agents.whisper.online
# ✓ DNSSEC        IANA root -> ... -> demo.agents.whisper.online (RRSIGs valid)
# ✓ DANE-EE       served leaf SPKI == TLSA 3 1 1 b653a4ef...fcb82d1d
# ✓ identity_doc  ES256 identity key (apex JWKS, DNSSEC-anchored) — claims match
# CRYPTOGRAPHICALLY PROVEN — Whisper API not trusted
```

The verdict is about the **name**, and that's exactly the anchor stock tools can't give you: once `--trustless` proves the DNSSEC chain down to this name, any signing key published under it — the `#sig-1` `verificationMethod`, a JWK `TXT` — inherits that same anchoring, so a forged or MITM'd `did.json` fails the chain instead of silently handing you an attacker's key. Feed the exit code into a CI gate — `whisper verify --trustless "$fqdn" && python3 verify_output.py` — and the whole thing fails closed.

## Bonus: signing commits with SSHFP

The same "publish the key at the name, verify against DNS" shape works for git provenance too, with primitives that predate JWS. OpenSSH's native commit signing (`git config gpg.format ssh`, backed by `ssh-keygen -Y sign`/`-Y verify` and OpenSSH's `PROTOCOL.sshsig`) needs an `allowed_signers` file mapping a principal to a public key — normally curated by hand, copy-pasted once and trusted forever. [`SSHFP`](https://www.rfc-editor.org/rfc/rfc4255) (RFC 4255) is the DNS record built for exactly this problem — it's what lets `ssh`'s own `VerifyHostKeyDNS` skip `known_hosts` for host keys — but note precisely what it carries: an algorithm byte, a fingerprint-type byte, and a **hash** of the key, not the key itself:

```
acef2002a323d40d4.demo.agents.whisper.online. IN SSHFP 4 2 <sha256-of-signing-key>
```

That's enough to *confirm* a key you already have in hand, not enough to reconstruct one from scratch — so the full public key still needs to travel some other way (a TXT record, same DKIM-style pattern as `_whisper-identity`'s published JWK, or simply alongside the signature itself), and `SSHFP` is the DNSSEC-anchored cross-check that the key you were handed is the *right* one:

```bash
# fingerprint published under DNSSEC — confirms which key is authoritative
dig +short SSHFP acef2002a323d40d4.demo.agents.whisper.online
# 4 2 <sha256-of-signing-key>

ssh-keygen -lf agent-signing-key.pub    # must print the same SHA256 fingerprint

echo "acef2002a323d40d4.demo.agents.whisper.online $(cat agent-signing-key.pub)" > allowed_signers
ssh-keygen -Y verify -f allowed_signers -I "acef2002a323d40d4.demo.agents.whisper.online" \
  -n git -s commit.sig < commit_payload
```

The identity precedent this stands on — a DNSSEC-signed name as the source of truth for a public key — is exactly the DANE pattern the [`TLSA`](/docs/dane) record already ships for the agent's TLS key; `SSHFP` is the same idea, RFC 4255 instead of RFC 6698, for a different key use.

## Why this beats a "signed by" footer — and what it doesn't do

A text footer is unforgeable only in the sense that forging it takes zero effort; anyone can type "signed by Agent-7." A DNS-anchored signature is unforgeable in the sense that matters: producing a valid one requires the private key an attacker doesn't have, and checking one requires nothing but a resolver and a JOSE library the recipient probably already ships. This is the same idea behind keyless code-signing schemes like Sigstore, minus the OIDC identity provider and the Fulcio CA in the middle — the identity binding here is DNSSEC + DANE, a chain that terminates at the IANA root instead of at a third-party signing service, and it works completely offline from any CI provider.

What signing an output does **not** prove is *when* — a signature alone can't stop someone from claiming an output existed earlier than it did. That's a separate, composable proof: see [OpenTimestamps & Bitcoin anchoring](/docs/opentimestamps) for pinning the *time* half onto the same chain, and [Transparency log](/docs/transparency) for making the issuance of the signing key itself independently auditable.

## Next

[did:web & verifiable credentials](/docs/did-web) for the document this key lives in · [DANE & DNSSEC](/docs/dane) for the chain that anchors it.
